@zhin.js/adapter-discord 5.0.1 → 5.0.3

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 (68) hide show
  1. package/CHANGELOG.md +55 -0
  2. package/README.md +51 -76
  3. package/adapters/discord.ts +46 -0
  4. package/agent/tools/add_role.ts +24 -0
  5. package/agent/tools/create_thread.ts +25 -0
  6. package/agent/tools/forum_post.ts +24 -0
  7. package/agent/tools/list_roles.ts +22 -0
  8. package/agent/tools/react.ts +22 -0
  9. package/agent/tools/remove_role.ts +24 -0
  10. package/agent/tools/send_embed.ts +37 -0
  11. package/lib/discord-agent-deps.d.ts +35 -0
  12. package/lib/discord-agent-deps.js +32 -0
  13. package/lib/endpoint.d.ts +66 -119
  14. package/lib/endpoint.js +308 -1047
  15. package/lib/gateway.d.ts +122 -0
  16. package/lib/gateway.js +235 -0
  17. package/lib/index.d.ts +6 -18
  18. package/lib/index.js +6 -330
  19. package/lib/platform-permit.d.ts +1 -2
  20. package/lib/platform-permit.js +4 -2
  21. package/lib/protocol.d.ts +135 -0
  22. package/lib/protocol.js +234 -0
  23. package/lib/webhook.d.ts +13 -0
  24. package/lib/webhook.js +86 -0
  25. package/package.json +48 -31
  26. package/plugin.ts +13 -0
  27. package/schema.json +63 -0
  28. package/src/discord-agent-deps.ts +79 -0
  29. package/src/endpoint.ts +385 -1167
  30. package/src/gateway.ts +337 -0
  31. package/src/index.ts +55 -332
  32. package/src/platform-permit.ts +1 -2
  33. package/src/protocol.ts +392 -0
  34. package/src/webhook.ts +121 -0
  35. package/client/Dashboard.tsx +0 -195
  36. package/client/index.tsx +0 -11
  37. package/client/tsconfig.json +0 -7
  38. package/client/utils/api.ts +0 -30
  39. package/dist/index.js +0 -29
  40. package/lib/adapter.d.ts +0 -25
  41. package/lib/adapter.d.ts.map +0 -1
  42. package/lib/adapter.js +0 -96
  43. package/lib/adapter.js.map +0 -1
  44. package/lib/endpoint-interactions.d.ts +0 -34
  45. package/lib/endpoint-interactions.d.ts.map +0 -1
  46. package/lib/endpoint-interactions.js +0 -284
  47. package/lib/endpoint-interactions.js.map +0 -1
  48. package/lib/endpoint.d.ts.map +0 -1
  49. package/lib/endpoint.js.map +0 -1
  50. package/lib/index.d.ts.map +0 -1
  51. package/lib/index.js.map +0 -1
  52. package/lib/platform-permit.d.ts.map +0 -1
  53. package/lib/platform-permit.js.map +0 -1
  54. package/lib/segment-mapper.d.ts +0 -2
  55. package/lib/segment-mapper.d.ts.map +0 -1
  56. package/lib/segment-mapper.js +0 -2
  57. package/lib/segment-mapper.js.map +0 -1
  58. package/lib/types.d.ts +0 -49
  59. package/lib/types.d.ts.map +0 -1
  60. package/lib/types.js +0 -2
  61. package/lib/types.js.map +0 -1
  62. package/plugin.yml +0 -3
  63. package/src/adapter.ts +0 -108
  64. package/src/endpoint-interactions.ts +0 -354
  65. package/src/segment-mapper.ts +0 -1
  66. package/src/types.ts +0 -60
  67. /package/{skills/discord → agent}/PERMITS.md +0 -0
  68. /package/{skills/discord/SKILL.md → agent/skills/discord.md} +0 -0
@@ -0,0 +1,392 @@
1
+ /**
2
+ * Discord Gateway protocol helpers — no legacy Adapter/Endpoint / segment-mapper.
3
+ * Canonicalization is owned by gateway/core before endpoint.send.
4
+ */
5
+
6
+ import { createPublicKey, verify as cryptoVerify } from 'node:crypto';
7
+
8
+ /** Plugin Runtime owner config (`plugins.<instanceKey>` / schema.json). */
9
+ export interface DiscordAdapterConfig {
10
+ readonly name?: string;
11
+ readonly token?: string;
12
+ /** Default `gateway`. `interactions` uses httpHostToken POST + Ed25519 verify. */
13
+ readonly connection?: 'gateway' | 'interactions';
14
+ readonly intents?: readonly number[];
15
+ readonly enableSlashCommands?: boolean;
16
+ readonly globalCommands?: boolean;
17
+ readonly defaultActivity?: {
18
+ readonly name: string;
19
+ readonly type: 'PLAYING' | 'STREAMING' | 'LISTENING' | 'WATCHING' | 'COMPETING';
20
+ readonly url?: string;
21
+ };
22
+ readonly slashCommands?: readonly Record<string, unknown>[];
23
+ /** Interactions-only fields. */
24
+ readonly applicationId?: string;
25
+ readonly publicKey?: string;
26
+ readonly interactionsPath?: string;
27
+ /** Transitional: legacy root `endpoints[]` with `context: discord`. */
28
+ readonly endpoints?: ReadonlyArray<{
29
+ readonly context?: string;
30
+ readonly name?: string;
31
+ readonly token?: string;
32
+ readonly connection?: 'gateway' | 'interactions';
33
+ readonly intents?: readonly number[];
34
+ readonly enableSlashCommands?: boolean;
35
+ readonly globalCommands?: boolean;
36
+ readonly defaultActivity?: DiscordAdapterConfig['defaultActivity'];
37
+ readonly slashCommands?: readonly Record<string, unknown>[];
38
+ readonly applicationId?: string;
39
+ readonly publicKey?: string;
40
+ readonly interactionsPath?: string;
41
+ }>;
42
+ }
43
+
44
+ export interface ResolvedDiscordGatewayConfig {
45
+ readonly context: 'discord';
46
+ readonly connection: 'gateway';
47
+ readonly name: string;
48
+ readonly token: string;
49
+ readonly intents?: readonly number[];
50
+ readonly enableSlashCommands: boolean;
51
+ readonly globalCommands: boolean;
52
+ readonly defaultActivity?: DiscordAdapterConfig['defaultActivity'];
53
+ readonly slashCommands?: readonly Record<string, unknown>[];
54
+ }
55
+
56
+ export interface ResolvedDiscordInteractionsConfig {
57
+ readonly context: 'discord';
58
+ readonly connection: 'interactions';
59
+ readonly name: string;
60
+ readonly token: string;
61
+ readonly applicationId: string;
62
+ readonly publicKey: string;
63
+ readonly interactionsPath: string;
64
+ }
65
+
66
+ export type ResolvedDiscordConfig =
67
+ | ResolvedDiscordGatewayConfig
68
+ | ResolvedDiscordInteractionsConfig;
69
+
70
+ export interface DiscordInboundAttachment {
71
+ readonly id?: string;
72
+ readonly name?: string;
73
+ readonly url?: string;
74
+ readonly contentType?: string;
75
+ readonly size?: number;
76
+ }
77
+
78
+ export interface DiscordInboundMessage {
79
+ readonly id: string;
80
+ readonly content: string;
81
+ readonly channelId: string;
82
+ readonly channelKind: 'private' | 'group' | 'channel';
83
+ readonly authorId: string;
84
+ readonly authorName: string;
85
+ readonly authorBot?: boolean;
86
+ readonly createdTimestamp: number;
87
+ readonly guildId?: string;
88
+ readonly isGuildOwner?: boolean;
89
+ readonly permissionTokens?: readonly string[];
90
+ readonly attachments?: readonly DiscordInboundAttachment[];
91
+ readonly embedTitles?: readonly string[];
92
+ readonly stickerNames?: readonly string[];
93
+ readonly replyToId?: string;
94
+ /** 入站 mentions 数组含 bot 用户时由 gateway connect 装配标注(Message.content 纯文本,@ 只能走 metadata)。 */
95
+ readonly mentionedBot?: boolean;
96
+ }
97
+
98
+ export interface DiscordButtonInbound {
99
+ readonly id: string;
100
+ readonly customId: string;
101
+ readonly channelId: string;
102
+ readonly channelKind: 'private' | 'group' | 'channel';
103
+ readonly userId: string;
104
+ readonly userName: string;
105
+ readonly sourceMessageId?: string;
106
+ }
107
+
108
+ export interface DiscordWireSegment {
109
+ readonly type: string;
110
+ readonly data?: Record<string, unknown>;
111
+ }
112
+
113
+ export interface DiscordOutboundComponentButton {
114
+ type: 2;
115
+ custom_id: string;
116
+ label: string;
117
+ style: number;
118
+ disabled?: boolean;
119
+ }
120
+
121
+ export interface DiscordOutboundActionRow {
122
+ type: 1;
123
+ components: DiscordOutboundComponentButton[];
124
+ }
125
+
126
+ export interface DiscordOutboundBody {
127
+ readonly content?: string;
128
+ readonly embeds?: ReadonlyArray<Record<string, unknown>>;
129
+ readonly files?: ReadonlyArray<{
130
+ name: string;
131
+ url?: string;
132
+ file?: string;
133
+ }>;
134
+ readonly components?: ReadonlyArray<DiscordOutboundActionRow>;
135
+ }
136
+
137
+ export function resolveDiscordConfig(config: DiscordAdapterConfig = {}): ResolvedDiscordConfig {
138
+ const entry = config.endpoints?.find((item) => item.context === 'discord' || !item.context);
139
+ const token = (typeof config.token === 'string' && config.token)
140
+ || (typeof entry?.token === 'string' && entry.token)
141
+ || process.env.DISCORD_BOT_TOKEN
142
+ || '';
143
+ if (!token) {
144
+ throw new TypeError(
145
+ 'Discord adapter requires token (plugins.<key>.token or endpoints with context: discord)',
146
+ );
147
+ }
148
+ const name = (typeof config.name === 'string' && config.name)
149
+ || (typeof entry?.name === 'string' && entry.name)
150
+ || process.env.DISCORD_BOT_NAME
151
+ || 'discord-bot';
152
+ const connection = config.connection
153
+ ?? entry?.connection
154
+ ?? 'gateway';
155
+
156
+ if (connection === 'interactions') {
157
+ const applicationId = config.applicationId || entry?.applicationId || '';
158
+ const publicKey = config.publicKey || entry?.publicKey || '';
159
+ if (!applicationId || !publicKey) {
160
+ throw new TypeError(
161
+ 'Discord connection:interactions requires applicationId and publicKey',
162
+ );
163
+ }
164
+ return {
165
+ context: 'discord',
166
+ connection: 'interactions',
167
+ name,
168
+ token,
169
+ applicationId,
170
+ publicKey,
171
+ interactionsPath: config.interactionsPath || entry?.interactionsPath || '/discord/interactions',
172
+ };
173
+ }
174
+
175
+ return {
176
+ context: 'discord',
177
+ connection: 'gateway',
178
+ name,
179
+ token,
180
+ intents: config.intents ?? entry?.intents,
181
+ enableSlashCommands: config.enableSlashCommands === true
182
+ || entry?.enableSlashCommands === true,
183
+ globalCommands: config.globalCommands === true || entry?.globalCommands === true,
184
+ defaultActivity: config.defaultActivity ?? entry?.defaultActivity,
185
+ slashCommands: config.slashCommands ?? entry?.slashCommands,
186
+ };
187
+ }
188
+
189
+ export function resolveChannelKind(channelType: number | string | undefined): 'private' | 'group' | 'channel' {
190
+ // discord.js ChannelType.DM = 1, GroupDM = 3
191
+ if (channelType === 1 || channelType === 'DM' || channelType === 'private') return 'private';
192
+ if (channelType === 3 || channelType === 'GroupDM' || channelType === 'group') return 'group';
193
+ return 'channel';
194
+ }
195
+
196
+ export function senderDisplayName(msg: DiscordInboundMessage): string {
197
+ return msg.authorName || msg.authorId;
198
+ }
199
+
200
+ /** Build inbound text for MessageGateway.receive (gateway owns reply routing). */
201
+ export function formatInboundContent(msg: DiscordInboundMessage): string {
202
+ const parts: string[] = [];
203
+ if (msg.replyToId) parts.push(`[reply:${msg.replyToId}]`);
204
+ if (msg.content?.trim()) parts.push(msg.content.trim());
205
+ for (const attachment of msg.attachments ?? []) {
206
+ const kind = attachment.contentType?.startsWith('image/')
207
+ ? 'image'
208
+ : attachment.contentType?.startsWith('audio/')
209
+ ? 'audio'
210
+ : attachment.contentType?.startsWith('video/')
211
+ ? 'video'
212
+ : 'file';
213
+ const name = attachment.name || attachment.url || 'attachment';
214
+ parts.push(`[${kind}: ${name}]`);
215
+ }
216
+ for (const title of msg.embedTitles ?? []) {
217
+ parts.push(`[embed: ${title}]`);
218
+ }
219
+ for (const name of msg.stickerNames ?? []) {
220
+ parts.push(`[sticker: ${name}]`);
221
+ }
222
+ const text = parts.join('\n').trim();
223
+ return text || '(Empty message)';
224
+ }
225
+
226
+ export function formatButtonContent(interaction: DiscordButtonInbound): string {
227
+ return `[action: ${interaction.customId}]`;
228
+ }
229
+
230
+ /**
231
+ * Wire-encode an already-rendered outbound payload into Discord message body.
232
+ * Segment canonicalization is intentionally not done here.
233
+ */
234
+ export function formatOutboundBody(payload: unknown): DiscordOutboundBody {
235
+ if (typeof payload === 'string') {
236
+ return { content: payload };
237
+ }
238
+
239
+ const segments: Array<string | DiscordWireSegment> = Array.isArray(payload)
240
+ ? payload as Array<string | DiscordWireSegment>
241
+ : payload && typeof payload === 'object' && 'type' in (payload as object)
242
+ ? [payload as DiscordWireSegment]
243
+ : [];
244
+
245
+ if (segments.length === 0) {
246
+ return {
247
+ content: payload == null
248
+ ? ''
249
+ : typeof payload === 'object'
250
+ ? JSON.stringify(payload)
251
+ : String(payload),
252
+ };
253
+ }
254
+
255
+ let content = '';
256
+ const embeds: Record<string, unknown>[] = [];
257
+ const files: Array<{ name: string; url?: string; file?: string }> = [];
258
+ let components: DiscordOutboundBody['components'];
259
+
260
+ for (const item of segments) {
261
+ if (typeof item === 'string') {
262
+ content += item;
263
+ continue;
264
+ }
265
+ const data = item.data ?? {};
266
+ switch (item.type) {
267
+ case 'text':
268
+ content += String(data.text ?? data.content ?? '');
269
+ break;
270
+ case 'at':
271
+ content += `<@${String(data.id ?? '')}>`;
272
+ break;
273
+ case 'channel_mention':
274
+ content += `<#${String(data.id ?? '')}>`;
275
+ break;
276
+ case 'role_mention':
277
+ content += `<@&${String(data.id ?? '')}>`;
278
+ break;
279
+ case 'emoji':
280
+ content += data.animated
281
+ ? `<a:${String(data.name)}:${String(data.id)}>`
282
+ : `<:${String(data.name)}:${String(data.id)}>`;
283
+ break;
284
+ case 'image':
285
+ case 'audio':
286
+ case 'video':
287
+ case 'file': {
288
+ const name = String(data.name || data.filename || item.type);
289
+ if (typeof data.file === 'string' && data.file) {
290
+ files.push({ name, file: data.file });
291
+ } else if (typeof data.url === 'string' && data.url) {
292
+ files.push({ name, url: data.url });
293
+ }
294
+ break;
295
+ }
296
+ case 'embed':
297
+ embeds.push({ ...data });
298
+ break;
299
+ case 'keyboard': {
300
+ const rows = (data.rows ?? []) as Array<Array<{
301
+ label: string;
302
+ payload: string;
303
+ disabled?: boolean;
304
+ style?: string;
305
+ }>>;
306
+ components = rows.map((row) => ({
307
+ type: 1 as const,
308
+ components: row.map((btn) => ({
309
+ type: 2 as const,
310
+ custom_id: String(btn.payload).slice(0, 100),
311
+ label: btn.label,
312
+ style: btn.style === 'danger' ? 4 : btn.style === 'primary' ? 1 : 2,
313
+ disabled: !!btn.disabled,
314
+ })),
315
+ }));
316
+ break;
317
+ }
318
+ default:
319
+ if (data.text != null) content += String(data.text);
320
+ break;
321
+ }
322
+ }
323
+
324
+ return {
325
+ ...(content.trim() ? { content: content.trim() } : {}),
326
+ ...(embeds.length > 0 ? { embeds: embeds.slice(0, 10) } : {}),
327
+ ...(files.length > 0 ? { files } : {}),
328
+ ...(components ? { components } : {}),
329
+ };
330
+ }
331
+
332
+ export function activityTypeCode(
333
+ type: NonNullable<DiscordAdapterConfig['defaultActivity']>['type'],
334
+ ): number {
335
+ const map = {
336
+ PLAYING: 0,
337
+ STREAMING: 1,
338
+ LISTENING: 2,
339
+ WATCHING: 3,
340
+ COMPETING: 5,
341
+ } as const;
342
+ return map[type] ?? 0;
343
+ }
344
+
345
+ export function verifyDiscordInteractionSignature(
346
+ publicKeyHex: string,
347
+ body: string,
348
+ signature: string,
349
+ timestamp: string,
350
+ ): boolean {
351
+ if (!publicKeyHex || !signature || !timestamp) return false;
352
+ try {
353
+ const key = createPublicKey({
354
+ key: Buffer.concat([
355
+ Buffer.from('302a300506032b6570032100', 'hex'),
356
+ Buffer.from(publicKeyHex, 'hex'),
357
+ ]),
358
+ format: 'der',
359
+ type: 'spki',
360
+ });
361
+ return cryptoVerify(null, Buffer.from(timestamp + body), key, Buffer.from(signature, 'hex'));
362
+ } catch {
363
+ return false;
364
+ }
365
+ }
366
+
367
+ export function formatSlashCommandContent(interaction: Record<string, unknown>): string {
368
+ const data = interaction.data as {
369
+ name?: string;
370
+ options?: Array<{ name: string; value: unknown }>;
371
+ } | undefined;
372
+ const parts = [`/${data?.name ?? 'command'}`];
373
+ for (const opt of data?.options ?? []) {
374
+ parts.push(`${opt.name}:${String(opt.value)}`);
375
+ }
376
+ return parts.join(' ');
377
+ }
378
+
379
+ export function interactionToInboundMessage(interaction: Record<string, unknown>): DiscordInboundMessage {
380
+ const user = (interaction.member as { user?: Record<string, unknown> } | undefined)?.user
381
+ ?? (interaction.user as Record<string, unknown> | undefined);
382
+ return {
383
+ id: String(interaction.id),
384
+ content: formatSlashCommandContent(interaction),
385
+ channelId: String(interaction.channel_id ?? ''),
386
+ channelKind: interaction.guild_id ? 'channel' : 'private',
387
+ authorId: String(user?.id ?? ''),
388
+ authorName: String(user?.username ?? user?.id ?? ''),
389
+ createdTimestamp: Date.now(),
390
+ guildId: interaction.guild_id != null ? String(interaction.guild_id) : undefined,
391
+ };
392
+ }
package/src/webhook.ts ADDED
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Discord interactions HTTP: signature → parse → admit.
3
+ */
4
+ import type { IncomingMessage, ServerResponse } from 'node:http';
5
+ import type { HttpHost, HttpRouteRegistration } from '@zhin.js/host-http';
6
+ import { formatCompact, getLogger } from '@zhin.js/logger';
7
+ import {
8
+ interactionToInboundMessage,
9
+ verifyDiscordInteractionSignature,
10
+ type DiscordInboundMessage,
11
+ type ResolvedDiscordInteractionsConfig,
12
+ } from './protocol.js';
13
+
14
+ const logger = getLogger('discord');
15
+
16
+ const INTERACTION_TYPE_PING = 1;
17
+ const INTERACTION_TYPE_APPLICATION_COMMAND = 2;
18
+ const INTERACTION_RESPONSE_PONG = 1;
19
+ const INTERACTION_RESPONSE_CHANNEL_MESSAGE_WITH_SOURCE = 4;
20
+ /** EPHEHEMERAL — 仅发起者可见(对齐旧 endpoint-interactions 默认响应)。 */
21
+ const INTERACTION_FLAG_EPHEMERAL = 64;
22
+
23
+ export interface DiscordInteractionsHandler {
24
+ readonly config: ResolvedDiscordInteractionsConfig;
25
+ readonly isOpen: boolean;
26
+ admit(msg: DiscordInboundMessage): void;
27
+ }
28
+
29
+ export function registerDiscordInteractionRoutes(
30
+ http: HttpHost,
31
+ handler: DiscordInteractionsHandler,
32
+ ): HttpRouteRegistration[] {
33
+ const path = handler.config.interactionsPath;
34
+ return [
35
+ http.route('POST', path, async (request, response) => {
36
+ await handleDiscordInteractionRequest(request, response, handler);
37
+ }, { summary: 'Discord interactions callback', tags: ['discord'] }),
38
+ ];
39
+ }
40
+
41
+ export async function handleDiscordInteractionRequest(
42
+ request: IncomingMessage,
43
+ response: ServerResponse,
44
+ handler: DiscordInteractionsHandler,
45
+ ): Promise<void> {
46
+ try {
47
+ const signature = headerValue(request.headers['x-signature-ed25519']);
48
+ const timestamp = headerValue(request.headers['x-signature-timestamp']);
49
+ const rawBody = await readInteractionBody(request);
50
+ if (!signature || !timestamp) {
51
+ response.writeHead(401, { 'Content-Type': 'text/plain' });
52
+ response.end('Unauthorized');
53
+ return;
54
+ }
55
+ if (!verifyDiscordInteractionSignature(
56
+ handler.config.publicKey,
57
+ rawBody,
58
+ signature,
59
+ timestamp,
60
+ )) {
61
+ response.writeHead(401, { 'Content-Type': 'text/plain' });
62
+ response.end('Unauthorized');
63
+ return;
64
+ }
65
+ const interaction = JSON.parse(rawBody) as Record<string, unknown>;
66
+ if (interaction.type === INTERACTION_TYPE_PING) {
67
+ writeJson(response, 200, { type: INTERACTION_RESPONSE_PONG });
68
+ return;
69
+ }
70
+ if (interaction.type === INTERACTION_TYPE_APPLICATION_COMMAND) {
71
+ if (handler.isOpen) {
72
+ handler.admit(interactionToInboundMessage(interaction));
73
+ }
74
+ // 即时响应(type 4):defer(type 5) 需要 followup PATCH,未实现会让用户端一直转圈
75
+ const commandName = String(
76
+ (interaction.data as { name?: unknown } | undefined)?.name ?? '',
77
+ );
78
+ writeJson(response, 200, {
79
+ type: INTERACTION_RESPONSE_CHANNEL_MESSAGE_WITH_SOURCE,
80
+ data: {
81
+ content: `处理命令: ${commandName}`,
82
+ flags: INTERACTION_FLAG_EPHEMERAL,
83
+ },
84
+ });
85
+ return;
86
+ }
87
+ response.writeHead(400, { 'Content-Type': 'text/plain' });
88
+ response.end('Unsupported interaction type');
89
+ } catch (error) {
90
+ logger.error('Discord interactions error:', error);
91
+ if (!response.headersSent) {
92
+ response.writeHead(500, { 'Content-Type': 'text/plain' });
93
+ response.end('Internal Server Error');
94
+ }
95
+ }
96
+ }
97
+
98
+ function headerValue(value: string | string[] | undefined): string {
99
+ if (Array.isArray(value)) return value[0] ?? '';
100
+ return value ?? '';
101
+ }
102
+
103
+ async function readInteractionBody(request: IncomingMessage): Promise<string> {
104
+ const chunks: Buffer[] = [];
105
+ let size = 0;
106
+ for await (const chunk of request) {
107
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
108
+ size += buffer.length;
109
+ if (size > 1_048_576) {
110
+ request.destroy();
111
+ throw new Error('Request body exceeds 1MB');
112
+ }
113
+ chunks.push(buffer);
114
+ }
115
+ return Buffer.concat(chunks).toString('utf8');
116
+ }
117
+
118
+ function writeJson(response: ServerResponse, status: number, body: unknown): void {
119
+ response.writeHead(status, { 'Content-Type': 'application/json' });
120
+ response.end(JSON.stringify(body));
121
+ }