@worca/ui 0.9.0 → 0.11.0-rc.1

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.
Files changed (33) hide show
  1. package/app/main.bundle.js +895 -813
  2. package/app/main.bundle.js.map +4 -4
  3. package/app/styles.css +216 -9
  4. package/app/utils/state-actions.js +55 -0
  5. package/package.json +6 -4
  6. package/server/app.js +291 -6
  7. package/server/beads-reader.js +1 -1
  8. package/server/dispatch-external.js +106 -0
  9. package/server/ensure-webhook.js +66 -0
  10. package/server/index.js +22 -0
  11. package/server/integrations/adapter.js +91 -0
  12. package/server/integrations/adapters/discord.js +109 -0
  13. package/server/integrations/adapters/slack.js +106 -0
  14. package/server/integrations/adapters/telegram.js +231 -0
  15. package/server/integrations/adapters/webhook_out.js +253 -0
  16. package/server/integrations/allowlist.js +19 -0
  17. package/server/integrations/chat_context.js +68 -0
  18. package/server/integrations/commands/control.js +120 -0
  19. package/server/integrations/commands/global.js +239 -0
  20. package/server/integrations/commands/parser.js +29 -0
  21. package/server/integrations/commands/project.js +394 -0
  22. package/server/integrations/config-loader.js +40 -0
  23. package/server/integrations/index.js +390 -0
  24. package/server/integrations/markdown.js +220 -0
  25. package/server/integrations/rate_limiter.js +131 -0
  26. package/server/integrations/renderers.js +191 -0
  27. package/server/integrations/rest_client.js +17 -0
  28. package/server/integrations/verify.js +23 -0
  29. package/server/process-manager.js +217 -14
  30. package/server/project-routes.js +210 -44
  31. package/server/settings-validator.js +250 -0
  32. package/server/ws-beads-watcher.js +22 -6
  33. package/server/ws-message-router.js +1 -1
@@ -0,0 +1,390 @@
1
+ /**
2
+ * createIntegrations factory — boots enabled adapters, wires event fan-out
3
+ * and inbound command dispatch.
4
+ * @module integrations/index
5
+ */
6
+
7
+ import { join } from 'node:path';
8
+ import { createDiscordAdapter } from './adapters/discord.js';
9
+ import { createSlackAdapter } from './adapters/slack.js';
10
+ import { createTelegramAdapter } from './adapters/telegram.js';
11
+ import { createWebhookOutAdapter } from './adapters/webhook_out.js';
12
+ import { createAllowlistGuard } from './allowlist.js';
13
+ import { createChatContext } from './chat_context.js';
14
+ import { createControlHandlers } from './commands/control.js';
15
+ import { createGlobalHandlers } from './commands/global.js';
16
+ import { parseCommand } from './commands/parser.js';
17
+ import { createProjectHandlers } from './commands/project.js';
18
+ import { loadIntegrationsConfig } from './config-loader.js';
19
+ import { createRateLimiter } from './rate_limiter.js';
20
+ import { renderEvent } from './renderers.js';
21
+ import { createRestClient } from './rest_client.js';
22
+ import { verify } from './verify.js';
23
+
24
+ /** Symbol used to pass raw request body through the stored event for HMAC verification. */
25
+ export const RAW_BODY = Symbol('raw_body');
26
+
27
+ const NO_OP_STUB = {
28
+ onEvent() {},
29
+ status() {
30
+ return { enabled: false };
31
+ },
32
+ strictInboxVerification: false,
33
+ secrets: [],
34
+ };
35
+
36
+ /**
37
+ * @param {{
38
+ * port: number,
39
+ * host?: string,
40
+ * prefsDir: string,
41
+ * configPath: string,
42
+ * }} opts
43
+ * @returns {{ onEvent, status, strictInboxVerification: boolean, secrets: string[] }}
44
+ */
45
+ export function createIntegrations({
46
+ port,
47
+ host = '127.0.0.1',
48
+ prefsDir,
49
+ configPath,
50
+ }) {
51
+ let cfg = loadIntegrationsConfig(configPath);
52
+ if (!cfg || !cfg.enabled) return NO_OP_STUB;
53
+
54
+ let secrets = _loadSecrets(cfg);
55
+ const integrationsDir = join(prefsDir, 'integrations');
56
+ const chatContext = createChatContext(
57
+ join(integrationsDir, 'chat_context.json'),
58
+ );
59
+ const restClient = createRestClient({ host, port });
60
+
61
+ const globalHandlers = createGlobalHandlers({
62
+ chatContext,
63
+ prefsDir,
64
+ restClient,
65
+ });
66
+ const projectHandlers = createProjectHandlers({ chatContext, restClient });
67
+ const controlHandlers = createControlHandlers({ chatContext, restClient });
68
+ const allHandlers = {
69
+ ...globalHandlers,
70
+ ...projectHandlers,
71
+ ...controlHandlers,
72
+ };
73
+
74
+ // Mutable adapter registry — keyed by adapter name
75
+ const adapterMap = new Map(); // name → { adapter, adapterCfg }
76
+ const rateLimiters = new Map();
77
+ let allowlist = createAllowlistGuard(_collectAllowedIds(cfg));
78
+
79
+ let invalidSigEvents = 0;
80
+ let lastEventAt = null;
81
+
82
+ // Boot initial adapters from config
83
+ for (const entry of _bootAdapters(cfg, integrationsDir)) {
84
+ _startEntry(entry);
85
+ }
86
+
87
+ function _startEntry({ adapter, adapterCfg }) {
88
+ adapterMap.set(adapter.name, { adapter, adapterCfg });
89
+ rateLimiters.set(
90
+ adapter.name,
91
+ createRateLimiter({ ratePerMin: adapterCfg.rate_limit_per_min ?? 20 }),
92
+ );
93
+ if (adapter.supportsInbound) {
94
+ adapter.onInbound((msg) => _handleInbound(msg));
95
+ }
96
+ adapter
97
+ .start()
98
+ .catch((err) =>
99
+ console.error(
100
+ `[integrations] ${adapter.name} start error:`,
101
+ err.message,
102
+ ),
103
+ );
104
+ }
105
+
106
+ async function _stopAdapter(name) {
107
+ const entry = adapterMap.get(name);
108
+ if (!entry) return;
109
+ try {
110
+ await entry.adapter.stop();
111
+ } catch (err) {
112
+ console.error(`[integrations] ${name} stop error:`, err.message);
113
+ }
114
+ adapterMap.delete(name);
115
+ rateLimiters.delete(name);
116
+ }
117
+
118
+ /**
119
+ * Hot-reload a single adapter: re-reads config, stops the old instance,
120
+ * boots a new one. No-op if the adapter's config section is missing/disabled.
121
+ */
122
+ async function reloadAdapter(name) {
123
+ cfg = loadIntegrationsConfig(configPath) || cfg;
124
+ if (!cfg.enabled) return;
125
+ secrets = _loadSecrets(cfg);
126
+ allowlist = createAllowlistGuard(_collectAllowedIds(cfg));
127
+
128
+ // Stop existing adapter — must complete before booting new one
129
+ await _stopAdapter(name);
130
+
131
+ // Boot new adapter from fresh config
132
+ const entries = _bootAdapters({ [name]: cfg[name] }, integrationsDir);
133
+ for (const entry of entries) {
134
+ _startEntry(entry);
135
+ }
136
+ }
137
+
138
+ /**
139
+ * Remove a single adapter: stops and unregisters it, refreshes config.
140
+ */
141
+ async function removeAdapter(name) {
142
+ await _stopAdapter(name);
143
+ cfg = loadIntegrationsConfig(configPath) || cfg;
144
+ secrets = _loadSecrets(cfg);
145
+ allowlist = createAllowlistGuard(_collectAllowedIds(cfg));
146
+ }
147
+
148
+ async function _handleInbound(msg) {
149
+ if (!allowlist.isAllowed({ platform: msg.platform, chatId: msg.chatId }))
150
+ return;
151
+
152
+ const parsed = parseCommand(msg.text);
153
+ if (!parsed) return;
154
+
155
+ const chatKey = `${msg.platform}:${msg.chatId}`;
156
+ const handler = allHandlers[parsed.command];
157
+ let reply;
158
+
159
+ if (!handler) {
160
+ reply = `Unknown command /${parsed.command}. Try /help.`;
161
+ } else {
162
+ try {
163
+ reply = await handler(chatKey, parsed.args);
164
+ } catch (err) {
165
+ console.error('[integrations] command error:', err.message);
166
+ reply = 'Internal error — try again.';
167
+ }
168
+ }
169
+
170
+ if (reply) await _sendReply(msg.platform, msg.chatId, reply);
171
+ }
172
+
173
+ async function _sendReply(platform, chatId, text) {
174
+ const entry = adapterMap.get(platform);
175
+ if (!entry) return;
176
+ const msg = {
177
+ title: null,
178
+ body: [{ kind: 'markdown', value: text }],
179
+ severity: 'info',
180
+ };
181
+ const rl = rateLimiters.get(platform);
182
+ if (rl) {
183
+ await rl.send(msg, (m) => entry.adapter.send(chatId, m));
184
+ } else {
185
+ await entry.adapter.send(chatId, msg);
186
+ }
187
+ }
188
+
189
+ function onEvent(stored) {
190
+ const rawBody = stored[RAW_BODY];
191
+ const sigHeader = stored.headers?.['x-worca-signature'];
192
+
193
+ if (secrets.length > 0) {
194
+ if (!rawBody || !verify(rawBody, sigHeader, secrets)) {
195
+ invalidSigEvents++;
196
+ return;
197
+ }
198
+ }
199
+
200
+ lastEventAt = new Date().toISOString();
201
+ const envelope = stored.envelope;
202
+
203
+ for (const [, { adapter, adapterCfg }] of adapterMap) {
204
+ const events = adapterCfg.events ?? [];
205
+ if (!events.includes(envelope?.event_type)) continue;
206
+
207
+ const chatId = String(adapterCfg.chat_id ?? adapterCfg.channel_id ?? '');
208
+ const chatKey = `${adapter.name}:${chatId}`;
209
+
210
+ if (chatContext.isMuted(chatKey)) {
211
+ chatContext.incrementMuted(chatKey);
212
+ continue;
213
+ }
214
+
215
+ const msg = renderEvent(envelope);
216
+ if (!msg) continue;
217
+
218
+ const rl = rateLimiters.get(adapter.name);
219
+ const sendFn = (m) => adapter.send(chatId, m);
220
+
221
+ if (rl) {
222
+ rl.send(msg, sendFn).catch((err) =>
223
+ console.error(
224
+ `[integrations] ${adapter.name} send error:`,
225
+ err.message,
226
+ ),
227
+ );
228
+ } else {
229
+ sendFn(msg).catch((err) =>
230
+ console.error(
231
+ `[integrations] ${adapter.name} send error:`,
232
+ err.message,
233
+ ),
234
+ );
235
+ }
236
+ }
237
+ }
238
+
239
+ function status() {
240
+ return {
241
+ enabled: true,
242
+ strict_inbox_verification: cfg.strict_inbox_verification ?? false,
243
+ secrets_configured: secrets.length,
244
+ adapters: [...adapterMap.values()].map(({ adapter }) => {
245
+ const conn = adapter.connectionState?.() ?? {
246
+ state: 'n/a',
247
+ error: null,
248
+ };
249
+ return {
250
+ name: adapter.name,
251
+ enabled: true,
252
+ persistent: adapter.persistent ?? false,
253
+ connection: conn.state,
254
+ connection_error: conn.error,
255
+ dropped_messages:
256
+ rateLimiters.get(adapter.name)?.getStats().dropped_messages ?? 0,
257
+ invalid_signature_events: invalidSigEvents,
258
+ last_event_at: lastEventAt,
259
+ };
260
+ }),
261
+ chats: _collectChatStatus(cfg, chatContext),
262
+ };
263
+ }
264
+
265
+ return {
266
+ onEvent,
267
+ status,
268
+ reloadAdapter,
269
+ removeAdapter,
270
+ /** @internal — used by detect endpoint to pause/resume adapter */
271
+ _getAdapter: (name) => adapterMap.get(name) ?? null,
272
+ strictInboxVerification: cfg.strict_inbox_verification ?? false,
273
+ secrets,
274
+ };
275
+ }
276
+
277
+ // ---------------------------------------------------------------------------
278
+ // Helpers
279
+ // ---------------------------------------------------------------------------
280
+
281
+ function _loadSecrets(cfg) {
282
+ const secrets = [];
283
+ if (cfg.webhook_secret_env) {
284
+ const val = process.env[cfg.webhook_secret_env];
285
+ if (val) secrets.push(val);
286
+ }
287
+ if (cfg.webhook_secrets_env) {
288
+ const val = process.env[cfg.webhook_secrets_env];
289
+ if (val) {
290
+ for (const s of val.split(',')) {
291
+ const trimmed = s.trim();
292
+ if (trimmed) secrets.push(trimmed);
293
+ }
294
+ }
295
+ }
296
+ return secrets;
297
+ }
298
+
299
+ function _bootAdapters(cfg, integrationsDir) {
300
+ const adapters = [];
301
+
302
+ if (cfg.telegram?.enabled) {
303
+ const token =
304
+ cfg.telegram.bot_token ||
305
+ process.env[cfg.telegram.bot_token_env || 'TELEGRAM_BOT_TOKEN'];
306
+ if (!token) {
307
+ console.warn('[integrations] telegram token not configured — skipping');
308
+ } else {
309
+ adapters.push({
310
+ adapter: createTelegramAdapter({
311
+ token,
312
+ cursorPath: join(integrationsDir, 'telegram.cursor'),
313
+ }),
314
+ adapterCfg: cfg.telegram,
315
+ });
316
+ }
317
+ }
318
+
319
+ if (cfg.discord?.enabled) {
320
+ const botToken =
321
+ cfg.discord.bot_token ||
322
+ process.env[cfg.discord.bot_token_env || 'DISCORD_BOT_TOKEN'];
323
+ if (!botToken) {
324
+ console.warn('[integrations] discord token not configured — skipping');
325
+ } else {
326
+ adapters.push({
327
+ adapter: createDiscordAdapter({
328
+ botToken,
329
+ channelId: cfg.discord.channel_id,
330
+ }),
331
+ adapterCfg: cfg.discord,
332
+ });
333
+ }
334
+ }
335
+
336
+ if (cfg.slack?.enabled) {
337
+ const webhookUrl =
338
+ cfg.slack.webhook_url ||
339
+ process.env[cfg.slack.webhook_url_env || 'SLACK_WEBHOOK_URL'];
340
+ if (!webhookUrl) {
341
+ console.warn(
342
+ '[integrations] slack webhook URL not configured — skipping',
343
+ );
344
+ } else {
345
+ adapters.push({
346
+ adapter: createSlackAdapter({ webhookUrl }),
347
+ adapterCfg: cfg.slack,
348
+ });
349
+ }
350
+ }
351
+
352
+ if (cfg.webhook_out?.enabled) {
353
+ const endpoints = cfg.webhook_out.endpoints ?? [];
354
+ if (endpoints.length > 0) {
355
+ const events = [...new Set(endpoints.flatMap((ep) => ep.events ?? []))];
356
+ adapters.push({
357
+ adapter: createWebhookOutAdapter({ endpoints }),
358
+ adapterCfg: { ...cfg.webhook_out, events },
359
+ });
360
+ }
361
+ }
362
+
363
+ return adapters;
364
+ }
365
+
366
+ function _collectAllowedIds(cfg) {
367
+ const ids = [];
368
+ if (cfg.telegram?.chat_id) ids.push(String(cfg.telegram.chat_id));
369
+ if (cfg.discord?.channel_id) ids.push(String(cfg.discord.channel_id));
370
+ if (cfg.slack?.chat_id) ids.push(String(cfg.slack.chat_id));
371
+ return ids;
372
+ }
373
+
374
+ function _collectChatStatus(cfg, chatContext) {
375
+ const chats = [];
376
+ if (cfg.telegram?.chat_id) {
377
+ const chatKey = `telegram:${cfg.telegram.chat_id}`;
378
+ const id = cfg.telegram.chat_id;
379
+ const state = chatContext.get(chatKey);
380
+ const masked = id.length > 6 ? `${id.slice(0, 3)}***${id.slice(-3)}` : id;
381
+ chats.push({
382
+ platform: 'telegram',
383
+ chat_id: masked,
384
+ active_project: state.active_project,
385
+ muted_until: state.mute_until,
386
+ muted_messages: state.muted_messages,
387
+ });
388
+ }
389
+ return chats;
390
+ }
@@ -0,0 +1,220 @@
1
+ /**
2
+ * Markdown-to-native format converters for chat adapter responses.
3
+ *
4
+ * Command handlers write responses in standard markdown. Each adapter
5
+ * converts to its native format before sending.
6
+ *
7
+ * @module integrations/markdown
8
+ */
9
+
10
+ // ---------------------------------------------------------------------------
11
+ // Helpers
12
+ // ---------------------------------------------------------------------------
13
+
14
+ /**
15
+ * Escape HTML special characters in text (for Telegram HTML).
16
+ */
17
+ function escHtml(text) {
18
+ return text
19
+ .replace(/&/g, '&')
20
+ .replace(/</g, '&lt;')
21
+ .replace(/>/g, '&gt;');
22
+ }
23
+
24
+ /**
25
+ * Process a markdown string by extracting protected regions (code blocks and
26
+ * inline code) first, applying transformations to unprotected text, then
27
+ * restoring the protected regions.
28
+ *
29
+ * @param {string} md - Markdown input
30
+ * @param {(text: string) => string} transformText - Transform non-code text
31
+ * @param {(code: string) => string} transformCodeBlock - Transform ```block```
32
+ * @param {(code: string) => string} transformInlineCode - Transform `code`
33
+ * @returns {string}
34
+ */
35
+ function processWithCodeProtection(
36
+ md,
37
+ transformText,
38
+ transformCodeBlock,
39
+ transformInlineCode,
40
+ ) {
41
+ const placeholders = [];
42
+ let idx = 0;
43
+
44
+ function placeholder(value) {
45
+ const key = `\x00PH${idx++}\x00`;
46
+ placeholders.push({ key, value });
47
+ return key;
48
+ }
49
+
50
+ // 1. Protect fenced code blocks (``` ... ```)
51
+ let result = md.replace(/```([\s\S]*?)```/g, (_match, code) => {
52
+ return placeholder(transformCodeBlock(code));
53
+ });
54
+
55
+ // 2. Protect inline code (` ... `)
56
+ result = result.replace(/`([^`]+)`/g, (_match, code) => {
57
+ return placeholder(transformInlineCode(code));
58
+ });
59
+
60
+ // 3. Transform the remaining (unprotected) text
61
+ result = transformText(result);
62
+
63
+ // 4. Restore placeholders
64
+ for (const { key, value } of placeholders) {
65
+ result = result.replace(key, value);
66
+ }
67
+
68
+ return result;
69
+ }
70
+
71
+ // ---------------------------------------------------------------------------
72
+ // Telegram HTML
73
+ // ---------------------------------------------------------------------------
74
+
75
+ /**
76
+ * Convert standard markdown to Telegram HTML.
77
+ *
78
+ * Supports: bold, italic, code, code blocks, links, strikethrough.
79
+ * Escapes <, >, & in regular text before adding HTML tags.
80
+ *
81
+ * @param {string} md
82
+ * @returns {string}
83
+ */
84
+ export function toTelegramHtml(md) {
85
+ if (!md) return '';
86
+
87
+ return processWithCodeProtection(
88
+ md,
89
+ // Transform regular text
90
+ (text) => {
91
+ // Escape HTML entities in regular text first
92
+ text = text
93
+ .replace(/&/g, '&amp;')
94
+ .replace(/</g, '&lt;')
95
+ .replace(/>/g, '&gt;');
96
+ // Links: [text](url) → <a href="url">text</a>
97
+ text = text.replace(
98
+ /\[([^\]]+)\]\(([^)]+)\)/g,
99
+ (_m, label, url) => `<a href="${url}">${label}</a>`,
100
+ );
101
+ // Bold: **text** → <b>text</b>
102
+ text = text.replace(/\*\*(.+?)\*\*/g, '<b>$1</b>');
103
+ // Italic: *text* → <i>text</i>
104
+ text = text.replace(/\*(.+?)\*/g, '<i>$1</i>');
105
+ // Strikethrough: ~~text~~ → <s>text</s>
106
+ text = text.replace(/~~(.+?)~~/g, '<s>$1</s>');
107
+ return text;
108
+ },
109
+ // Code block: ```block``` → <pre>block</pre>
110
+ (code) => `<pre>${escHtml(code)}</pre>`,
111
+ // Inline code: `code` → <code>code</code>
112
+ (code) => `<code>${escHtml(code)}</code>`,
113
+ );
114
+ }
115
+
116
+ // ---------------------------------------------------------------------------
117
+ // Discord markdown (pass-through)
118
+ // ---------------------------------------------------------------------------
119
+
120
+ /**
121
+ * Convert standard markdown to Discord markdown.
122
+ * Discord supports standard markdown natively, so this is a pass-through.
123
+ *
124
+ * @param {string} md
125
+ * @returns {string}
126
+ */
127
+ export function toDiscordMarkdown(md) {
128
+ return md ?? '';
129
+ }
130
+
131
+ // ---------------------------------------------------------------------------
132
+ // Slack mrkdwn
133
+ // ---------------------------------------------------------------------------
134
+
135
+ /**
136
+ * Convert standard markdown to Slack mrkdwn format.
137
+ *
138
+ * Supports: bold, italic, code, code blocks, links, strikethrough.
139
+ *
140
+ * @param {string} md
141
+ * @returns {string}
142
+ */
143
+ export function toSlackMrkdwn(md) {
144
+ if (!md) return '';
145
+
146
+ return processWithCodeProtection(
147
+ md,
148
+ // Transform regular text
149
+ (text) => {
150
+ // Links: [text](url) → <url|text>
151
+ // Match links carefully — url part uses a greedy match up to the closing )
152
+ text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_m, label, url) => {
153
+ // Escape pipe characters in url and label
154
+ const safeUrl = url.replace(/\|/g, '%7C');
155
+ const safeLabel = label.replace(/\|/g, '\u2758');
156
+ return `<${safeUrl}|${safeLabel}>`;
157
+ });
158
+ // Italic (single *) BEFORE bold — we need to match single * that are NOT
159
+ // part of ** pairs. Convert italic first using a temp marker, then bold.
160
+ // Step 1: Convert bold **text** → placeholder
161
+ const boldParts = [];
162
+ let bIdx = 0;
163
+ text = text.replace(/\*\*(.+?)\*\*/g, (_m, content) => {
164
+ const key = `\x01B${bIdx++}\x01`;
165
+ boldParts.push({ key, content });
166
+ return key;
167
+ });
168
+ // Step 2: Convert remaining italic *text* → _text_
169
+ text = text.replace(/\*(.+?)\*/g, '_$1_');
170
+ // Step 3: Restore bold as Slack bold *text*
171
+ for (const { key, content } of boldParts) {
172
+ text = text.replace(key, `*${content}*`);
173
+ }
174
+ // Strikethrough: ~~text~~ → ~text~
175
+ text = text.replace(/~~(.+?)~~/g, '~$1~');
176
+ return text;
177
+ },
178
+ // Code blocks pass through as-is (``` is native in Slack)
179
+ (code) => `\`\`\`${code}\`\`\``,
180
+ // Inline code passes through as-is
181
+ (code) => `\`${code}\``,
182
+ );
183
+ }
184
+
185
+ // ---------------------------------------------------------------------------
186
+ // Plain text
187
+ // ---------------------------------------------------------------------------
188
+
189
+ /**
190
+ * Strip all markdown formatting and return plain text.
191
+ *
192
+ * @param {string} md
193
+ * @returns {string}
194
+ */
195
+ export function toPlainText(md) {
196
+ if (!md) return '';
197
+
198
+ return processWithCodeProtection(
199
+ md,
200
+ // Transform regular text
201
+ (text) => {
202
+ // Links: [text](url) → text (url)
203
+ text = text.replace(
204
+ /\[([^\]]+)\]\(([^)]+)\)/g,
205
+ (_m, label, url) => `${label} (${url})`,
206
+ );
207
+ // Bold: **text** → text
208
+ text = text.replace(/\*\*(.+?)\*\*/g, '$1');
209
+ // Italic: *text* → text
210
+ text = text.replace(/\*(.+?)\*/g, '$1');
211
+ // Strikethrough: ~~text~~ → text
212
+ text = text.replace(/~~(.+?)~~/g, '$1');
213
+ return text;
214
+ },
215
+ // Code blocks: just the content
216
+ (code) => code,
217
+ // Inline code: just the content
218
+ (code) => code,
219
+ );
220
+ }