@open-mercato/channel-discord 0.6.8-develop.7090.1.4dbca7b350

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 (220) hide show
  1. package/.turbo/turbo-build.log +2 -0
  2. package/build.mjs +7 -0
  3. package/dist/index.js +5 -0
  4. package/dist/index.js.map +7 -0
  5. package/dist/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-001.spec.js +86 -0
  6. package/dist/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-001.spec.js.map +7 -0
  7. package/dist/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-002.spec.js +39 -0
  8. package/dist/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-002.spec.js.map +7 -0
  9. package/dist/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-003.spec.js +97 -0
  10. package/dist/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-003.spec.js.map +7 -0
  11. package/dist/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-004.spec.js +65 -0
  12. package/dist/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-004.spec.js.map +7 -0
  13. package/dist/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-005.spec.js +110 -0
  14. package/dist/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-005.spec.js.map +7 -0
  15. package/dist/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-006.spec.js +103 -0
  16. package/dist/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-006.spec.js.map +7 -0
  17. package/dist/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-007.spec.js +93 -0
  18. package/dist/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-007.spec.js.map +7 -0
  19. package/dist/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-008.spec.js +52 -0
  20. package/dist/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-008.spec.js.map +7 -0
  21. package/dist/modules/channel_discord/__integration__/helpers/discordSignature.js +46 -0
  22. package/dist/modules/channel_discord/__integration__/helpers/discordSignature.js.map +7 -0
  23. package/dist/modules/channel_discord/__integration__/helpers/eventsQueue.js +16 -0
  24. package/dist/modules/channel_discord/__integration__/helpers/eventsQueue.js.map +7 -0
  25. package/dist/modules/channel_discord/__integration__/meta.js +10 -0
  26. package/dist/modules/channel_discord/__integration__/meta.js.map +7 -0
  27. package/dist/modules/channel_discord/acl.js +15 -0
  28. package/dist/modules/channel_discord/acl.js.map +7 -0
  29. package/dist/modules/channel_discord/ai-agents.js +97 -0
  30. package/dist/modules/channel_discord/ai-agents.js.map +7 -0
  31. package/dist/modules/channel_discord/api/ai-auto-reply/channels/route.js +73 -0
  32. package/dist/modules/channel_discord/api/ai-auto-reply/channels/route.js.map +7 -0
  33. package/dist/modules/channel_discord/api/channels/[id]/ai-auto-reply/route.js +222 -0
  34. package/dist/modules/channel_discord/api/channels/[id]/ai-auto-reply/route.js.map +7 -0
  35. package/dist/modules/channel_discord/api/interactions/route.js +173 -0
  36. package/dist/modules/channel_discord/api/interactions/route.js.map +7 -0
  37. package/dist/modules/channel_discord/backend/channel_discord/channels/[id]/ai-auto-reply/page.js +196 -0
  38. package/dist/modules/channel_discord/backend/channel_discord/channels/[id]/ai-auto-reply/page.js.map +7 -0
  39. package/dist/modules/channel_discord/backend/channel_discord/channels/[id]/ai-auto-reply/page.meta.js +14 -0
  40. package/dist/modules/channel_discord/backend/channel_discord/channels/[id]/ai-auto-reply/page.meta.js.map +7 -0
  41. package/dist/modules/channel_discord/cli.js +142 -0
  42. package/dist/modules/channel_discord/cli.js.map +7 -0
  43. package/dist/modules/channel_discord/commands/ai-reply-proposal.js +116 -0
  44. package/dist/modules/channel_discord/commands/ai-reply-proposal.js.map +7 -0
  45. package/dist/modules/channel_discord/commands/update-ai-auto-reply.js +104 -0
  46. package/dist/modules/channel_discord/commands/update-ai-auto-reply.js.map +7 -0
  47. package/dist/modules/channel_discord/di.js +23 -0
  48. package/dist/modules/channel_discord/di.js.map +7 -0
  49. package/dist/modules/channel_discord/index.js +9 -0
  50. package/dist/modules/channel_discord/index.js.map +7 -0
  51. package/dist/modules/channel_discord/integration.js +96 -0
  52. package/dist/modules/channel_discord/integration.js.map +7 -0
  53. package/dist/modules/channel_discord/lib/adapter.js +248 -0
  54. package/dist/modules/channel_discord/lib/adapter.js.map +7 -0
  55. package/dist/modules/channel_discord/lib/ai-agent-directory.js +45 -0
  56. package/dist/modules/channel_discord/lib/ai-agent-directory.js.map +7 -0
  57. package/dist/modules/channel_discord/lib/ai-features.js +9 -0
  58. package/dist/modules/channel_discord/lib/ai-features.js.map +7 -0
  59. package/dist/modules/channel_discord/lib/ai-proposal-contract.js +9 -0
  60. package/dist/modules/channel_discord/lib/ai-proposal-contract.js.map +7 -0
  61. package/dist/modules/channel_discord/lib/ai-proposal.js +109 -0
  62. package/dist/modules/channel_discord/lib/ai-proposal.js.map +7 -0
  63. package/dist/modules/channel_discord/lib/ai-reply.js +96 -0
  64. package/dist/modules/channel_discord/lib/ai-reply.js.map +7 -0
  65. package/dist/modules/channel_discord/lib/ai-service-principal.js +70 -0
  66. package/dist/modules/channel_discord/lib/ai-service-principal.js.map +7 -0
  67. package/dist/modules/channel_discord/lib/capabilities.js +42 -0
  68. package/dist/modules/channel_discord/lib/capabilities.js.map +7 -0
  69. package/dist/modules/channel_discord/lib/channel-access.js +62 -0
  70. package/dist/modules/channel_discord/lib/channel-access.js.map +7 -0
  71. package/dist/modules/channel_discord/lib/channel-state-store.js +103 -0
  72. package/dist/modules/channel_discord/lib/channel-state-store.js.map +7 -0
  73. package/dist/modules/channel_discord/lib/convert-outbound.js +38 -0
  74. package/dist/modules/channel_discord/lib/convert-outbound.js.map +7 -0
  75. package/dist/modules/channel_discord/lib/credentials.js +61 -0
  76. package/dist/modules/channel_discord/lib/credentials.js.map +7 -0
  77. package/dist/modules/channel_discord/lib/discord-gateway-client.js +289 -0
  78. package/dist/modules/channel_discord/lib/discord-gateway-client.js.map +7 -0
  79. package/dist/modules/channel_discord/lib/discord-rest.js +203 -0
  80. package/dist/modules/channel_discord/lib/discord-rest.js.map +7 -0
  81. package/dist/modules/channel_discord/lib/failure-reason.js +30 -0
  82. package/dist/modules/channel_discord/lib/failure-reason.js.map +7 -0
  83. package/dist/modules/channel_discord/lib/gateway-bridge.js +42 -0
  84. package/dist/modules/channel_discord/lib/gateway-bridge.js.map +7 -0
  85. package/dist/modules/channel_discord/lib/health.js +37 -0
  86. package/dist/modules/channel_discord/lib/health.js.map +7 -0
  87. package/dist/modules/channel_discord/lib/interactions-dispatch.js +164 -0
  88. package/dist/modules/channel_discord/lib/interactions-dispatch.js.map +7 -0
  89. package/dist/modules/channel_discord/lib/interactions-handler.js +115 -0
  90. package/dist/modules/channel_discord/lib/interactions-handler.js.map +7 -0
  91. package/dist/modules/channel_discord/lib/interactions-queue.js +17 -0
  92. package/dist/modules/channel_discord/lib/interactions-queue.js.map +7 -0
  93. package/dist/modules/channel_discord/lib/interactions-verify.js +84 -0
  94. package/dist/modules/channel_discord/lib/interactions-verify.js.map +7 -0
  95. package/dist/modules/channel_discord/lib/normalize-inbound.js +59 -0
  96. package/dist/modules/channel_discord/lib/normalize-inbound.js.map +7 -0
  97. package/dist/modules/channel_discord/lib/preset.js +63 -0
  98. package/dist/modules/channel_discord/lib/preset.js.map +7 -0
  99. package/dist/modules/channel_discord/lib/queue-strategy.js +12 -0
  100. package/dist/modules/channel_discord/lib/queue-strategy.js.map +7 -0
  101. package/dist/modules/channel_discord/lib/slash-commands.js +42 -0
  102. package/dist/modules/channel_discord/lib/slash-commands.js.map +7 -0
  103. package/dist/modules/channel_discord/message-types.js +64 -0
  104. package/dist/modules/channel_discord/message-types.js.map +7 -0
  105. package/dist/modules/channel_discord/setup.js +62 -0
  106. package/dist/modules/channel_discord/setup.js.map +7 -0
  107. package/dist/modules/channel_discord/subscribers/ai-auto-reply.js +185 -0
  108. package/dist/modules/channel_discord/subscribers/ai-auto-reply.js.map +7 -0
  109. package/dist/modules/channel_discord/widgets/injection/ai-auto-reply/widget.client.js +79 -0
  110. package/dist/modules/channel_discord/widgets/injection/ai-auto-reply/widget.client.js.map +7 -0
  111. package/dist/modules/channel_discord/widgets/injection/ai-auto-reply/widget.js +20 -0
  112. package/dist/modules/channel_discord/widgets/injection/ai-auto-reply/widget.js.map +7 -0
  113. package/dist/modules/channel_discord/widgets/injection/connect/widget.client.js +220 -0
  114. package/dist/modules/channel_discord/widgets/injection/connect/widget.client.js.map +7 -0
  115. package/dist/modules/channel_discord/widgets/injection/connect/widget.js +17 -0
  116. package/dist/modules/channel_discord/widgets/injection/connect/widget.js.map +7 -0
  117. package/dist/modules/channel_discord/widgets/injection-table.js +26 -0
  118. package/dist/modules/channel_discord/widgets/injection-table.js.map +7 -0
  119. package/dist/modules/channel_discord/workers/discord-gateway.js +270 -0
  120. package/dist/modules/channel_discord/workers/discord-gateway.js.map +7 -0
  121. package/dist/modules/channel_discord/workers/discord-interactions.js +113 -0
  122. package/dist/modules/channel_discord/workers/discord-interactions.js.map +7 -0
  123. package/jest.config.cjs +38 -0
  124. package/package.json +104 -0
  125. package/src/index.ts +1 -0
  126. package/src/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-001.spec.ts +109 -0
  127. package/src/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-002.spec.ts +58 -0
  128. package/src/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-003.spec.ts +175 -0
  129. package/src/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-004.spec.ts +83 -0
  130. package/src/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-005.spec.ts +156 -0
  131. package/src/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-006.spec.ts +144 -0
  132. package/src/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-007.spec.ts +119 -0
  133. package/src/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-008.spec.ts +75 -0
  134. package/src/modules/channel_discord/__integration__/helpers/discordSignature.ts +77 -0
  135. package/src/modules/channel_discord/__integration__/helpers/eventsQueue.ts +26 -0
  136. package/src/modules/channel_discord/__integration__/meta.ts +6 -0
  137. package/src/modules/channel_discord/__tests__/ai-auto-reply.policy.integration.test.ts +286 -0
  138. package/src/modules/channel_discord/acl.ts +19 -0
  139. package/src/modules/channel_discord/ai-agents.ts +157 -0
  140. package/src/modules/channel_discord/api/ai-auto-reply/channels/__tests__/route.test.ts +175 -0
  141. package/src/modules/channel_discord/api/ai-auto-reply/channels/route.ts +99 -0
  142. package/src/modules/channel_discord/api/channels/[id]/ai-auto-reply/__tests__/route.test.ts +311 -0
  143. package/src/modules/channel_discord/api/channels/[id]/ai-auto-reply/route.ts +299 -0
  144. package/src/modules/channel_discord/api/interactions/route.ts +242 -0
  145. package/src/modules/channel_discord/backend/channel_discord/channels/[id]/ai-auto-reply/page.meta.ts +18 -0
  146. package/src/modules/channel_discord/backend/channel_discord/channels/[id]/ai-auto-reply/page.tsx +288 -0
  147. package/src/modules/channel_discord/cli.ts +234 -0
  148. package/src/modules/channel_discord/commands/__tests__/ai-reply-proposal.test.ts +155 -0
  149. package/src/modules/channel_discord/commands/__tests__/update-ai-auto-reply.test.ts +185 -0
  150. package/src/modules/channel_discord/commands/ai-reply-proposal.ts +162 -0
  151. package/src/modules/channel_discord/commands/update-ai-auto-reply.ts +157 -0
  152. package/src/modules/channel_discord/di.ts +26 -0
  153. package/src/modules/channel_discord/i18n/de.json +57 -0
  154. package/src/modules/channel_discord/i18n/en.json +57 -0
  155. package/src/modules/channel_discord/i18n/es.json +57 -0
  156. package/src/modules/channel_discord/i18n/ko.json +57 -0
  157. package/src/modules/channel_discord/i18n/pl.json +57 -0
  158. package/src/modules/channel_discord/index.ts +6 -0
  159. package/src/modules/channel_discord/integration.ts +98 -0
  160. package/src/modules/channel_discord/lib/__tests__/adapter.test.ts +206 -0
  161. package/src/modules/channel_discord/lib/__tests__/ai-agent-directory.test.ts +61 -0
  162. package/src/modules/channel_discord/lib/__tests__/ai-reply.test.ts +115 -0
  163. package/src/modules/channel_discord/lib/__tests__/capabilities.test.ts +170 -0
  164. package/src/modules/channel_discord/lib/__tests__/channel-quarantine.test.ts +89 -0
  165. package/src/modules/channel_discord/lib/__tests__/channel-state-store.auto-reply-marker.test.ts +145 -0
  166. package/src/modules/channel_discord/lib/__tests__/channel-state-store.test.ts +78 -0
  167. package/src/modules/channel_discord/lib/__tests__/convert-outbound.test.ts +39 -0
  168. package/src/modules/channel_discord/lib/__tests__/discord-gateway-client.test.ts +243 -0
  169. package/src/modules/channel_discord/lib/__tests__/failure-reason.test.ts +68 -0
  170. package/src/modules/channel_discord/lib/__tests__/gateway-bridge.test.ts +62 -0
  171. package/src/modules/channel_discord/lib/__tests__/interactions-dispatch.test.ts +223 -0
  172. package/src/modules/channel_discord/lib/__tests__/interactions-handler.test.ts +469 -0
  173. package/src/modules/channel_discord/lib/__tests__/interactions-verify.test.ts +126 -0
  174. package/src/modules/channel_discord/lib/__tests__/normalize-inbound.identity.test.ts +64 -0
  175. package/src/modules/channel_discord/lib/__tests__/normalize-inbound.test.ts +67 -0
  176. package/src/modules/channel_discord/lib/__tests__/preset.test.ts +110 -0
  177. package/src/modules/channel_discord/lib/__tests__/queue-strategy.test.ts +33 -0
  178. package/src/modules/channel_discord/lib/adapter.ts +359 -0
  179. package/src/modules/channel_discord/lib/ai-agent-directory.ts +124 -0
  180. package/src/modules/channel_discord/lib/ai-features.ts +23 -0
  181. package/src/modules/channel_discord/lib/ai-proposal-contract.ts +24 -0
  182. package/src/modules/channel_discord/lib/ai-proposal.ts +179 -0
  183. package/src/modules/channel_discord/lib/ai-reply.ts +179 -0
  184. package/src/modules/channel_discord/lib/ai-service-principal.ts +143 -0
  185. package/src/modules/channel_discord/lib/capabilities.ts +104 -0
  186. package/src/modules/channel_discord/lib/channel-access.ts +110 -0
  187. package/src/modules/channel_discord/lib/channel-state-store.ts +217 -0
  188. package/src/modules/channel_discord/lib/convert-outbound.ts +88 -0
  189. package/src/modules/channel_discord/lib/credentials.ts +88 -0
  190. package/src/modules/channel_discord/lib/discord-gateway-client.ts +432 -0
  191. package/src/modules/channel_discord/lib/discord-rest.ts +393 -0
  192. package/src/modules/channel_discord/lib/failure-reason.ts +49 -0
  193. package/src/modules/channel_discord/lib/gateway-bridge.ts +79 -0
  194. package/src/modules/channel_discord/lib/health.ts +55 -0
  195. package/src/modules/channel_discord/lib/interactions-dispatch.ts +288 -0
  196. package/src/modules/channel_discord/lib/interactions-handler.ts +267 -0
  197. package/src/modules/channel_discord/lib/interactions-queue.ts +52 -0
  198. package/src/modules/channel_discord/lib/interactions-verify.ts +174 -0
  199. package/src/modules/channel_discord/lib/normalize-inbound.ts +89 -0
  200. package/src/modules/channel_discord/lib/preset.ts +109 -0
  201. package/src/modules/channel_discord/lib/queue-strategy.ts +29 -0
  202. package/src/modules/channel_discord/lib/slash-commands.ts +57 -0
  203. package/src/modules/channel_discord/message-types.ts +75 -0
  204. package/src/modules/channel_discord/setup.ts +78 -0
  205. package/src/modules/channel_discord/subscribers/__tests__/ai-auto-reply.armed.test.ts +149 -0
  206. package/src/modules/channel_discord/subscribers/__tests__/ai-auto-reply.failure-marker.test.ts +133 -0
  207. package/src/modules/channel_discord/subscribers/__tests__/ai-auto-reply.test.ts +273 -0
  208. package/src/modules/channel_discord/subscribers/ai-auto-reply.ts +325 -0
  209. package/src/modules/channel_discord/widgets/injection/ai-auto-reply/widget.client.tsx +143 -0
  210. package/src/modules/channel_discord/widgets/injection/ai-auto-reply/widget.ts +19 -0
  211. package/src/modules/channel_discord/widgets/injection/connect/widget.client.tsx +237 -0
  212. package/src/modules/channel_discord/widgets/injection/connect/widget.ts +16 -0
  213. package/src/modules/channel_discord/widgets/injection-table.ts +24 -0
  214. package/src/modules/channel_discord/workers/__tests__/discord-gateway.single-identify.test.ts +88 -0
  215. package/src/modules/channel_discord/workers/__tests__/discord-gateway.test.ts +179 -0
  216. package/src/modules/channel_discord/workers/__tests__/discord-interactions.test.ts +152 -0
  217. package/src/modules/channel_discord/workers/discord-gateway.ts +458 -0
  218. package/src/modules/channel_discord/workers/discord-interactions.ts +162 -0
  219. package/tsconfig.json +9 -0
  220. package/watch.mjs +7 -0
@@ -0,0 +1,104 @@
1
+ import type { ChannelCapabilities } from '@open-mercato/core/modules/communication_channels/lib/adapter'
2
+
3
+ /**
4
+ * Discord message content hard limit — the REST API rejects `content` longer
5
+ * than 2000 characters (embeds have their own separate budget we don't use here).
6
+ */
7
+ export const DISCORD_MAX_BODY_LENGTH = 2000
8
+
9
+ /**
10
+ * Discord capability profile (SPEC 2026-06-19 § Adapter method map).
11
+ *
12
+ * The hub treats these flags as a contract: it routes work to the adapter based
13
+ * on them, so each one describes what THIS adapter implements today, not what
14
+ * the Discord API is able to do. Everything the first release does not implement
15
+ * is declared `false` and stays `false` until the corresponding code lands.
16
+ *
17
+ * Enabled, with the implementation behind each:
18
+ * - `richText` / `supportedBodyFormats: ['text', 'markdown']` — Discord content
19
+ * is markdown-native; HTML is down-converted in `convertOutbound`.
20
+ * - `reactions` / `editMessage` / `deleteMessage` — backed by the matching
21
+ * `discord-rest` calls the adapter exposes.
22
+ * - `conversationHistory` — `fetchHistory` pages `GET /channels/{id}/messages`.
23
+ * - `realtimePush` — the provider owns a long-running Gateway WebSocket worker
24
+ * that delivers `MESSAGE_CREATE` / reaction events in real time, so the hub's
25
+ * polling scheduler skips this channel (no redundant `fetchHistory`).
26
+ * - `interactiveComponents` — slash commands, buttons, select menus and modal
27
+ * submissions round-trip for real: the signed Interactions endpoint answers
28
+ * with a deferred acknowledgement AND hands the interaction to
29
+ * `workers/discord-interactions.ts`, which normalizes it into the hub's
30
+ * existing inbound queue under the matched channel's tenant scope and then
31
+ * replaces the "thinking…" placeholder over Discord's interaction-webhook
32
+ * endpoints. Pinned by the parity test in `lib/__tests__/capabilities.test.ts`,
33
+ * which drives the whole path rather than asserting the flag alone.
34
+ *
35
+ * Deliberately disabled until implemented (declaring them would make the hub
36
+ * hand this adapter work it silently drops):
37
+ * - `threading` — `convertOutbound` reads `channelMetadata.replyToExternalId`
38
+ * and emits `message_reference` from it, but nothing hub-side ever writes that
39
+ * key into OUTBOUND metadata: `replyToExternalId` exists only on the inbound
40
+ * `NormalizedInboundMessage` shape, and the hub's outbound metadata producers
41
+ * (`send-as-user.ts`, `deliver-outbound-message.ts`) write the email-shaped
42
+ * `inReplyTo` / `references` instead. The conversion is therefore unreachable
43
+ * in production — confirmed against a live bot in #5541. The flag flips back
44
+ * to `true` in the same change that gives the hub an outbound reply producer
45
+ * this adapter can read, guarded by a contract test.
46
+ * - `fileSharing` / `inlineImages` — `convertOutbound` drops
47
+ * `input.content.attachments` and `discord-rest` has no multipart upload, so
48
+ * outbound attachments never reach Discord. `maxFileSize` /
49
+ * `supportedMimeTypes` are omitted for the same reason.
50
+ * - `typingIndicators` — no `POST /channels/{id}/typing` call exists here.
51
+ * - `presence` — the bot identifies without the `GUILD_PRESENCES` intent and no
52
+ * presence dispatch is handled.
53
+ * - `richBlocks` — outbound is plain markdown `content`; embeds are not built.
54
+ * - `stickers` — no sticker is sent or normalized.
55
+ *
56
+ * AI auto-reply (issue #4778) deliberately flips NONE of these back to `true`.
57
+ * `ChannelCapabilities` describes what the hub may hand this ADAPTER — message
58
+ * shapes, transport features, whether to schedule polling. Auto-reply is not an
59
+ * adapter capability at all: it is a subscriber on the hub's generic
60
+ * `message.received` event that composes a reply through the same outbound path
61
+ * any module uses. Adding a flag for it would tell the hub to route work here
62
+ * that the adapter does not implement, which is exactly the failure mode this
63
+ * list exists to prevent.
64
+ */
65
+ export const discordCapabilities: ChannelCapabilities = {
66
+ // A Discord recipient is a channel snowflake, never an address. Without this
67
+ // the hub falls back to its `'email'` default and `validateOutboundRecipient`
68
+ // rejects every real recipient with "Recipient must be a valid email address"
69
+ // — which is #4976 still broken for the provider it was filed against, even
70
+ // though #5261 built the mechanism to fix it. The hub then applies transport
71
+ // safety only (allowlist + length), and this adapter owns the format and must
72
+ // keep treating the value as untrusted.
73
+ recipientFormat: 'provider-native',
74
+
75
+ // Core
76
+ threading: false,
77
+ richText: true,
78
+ fileSharing: false,
79
+ readReceipts: false,
80
+ deliveryReceipts: false,
81
+ typingIndicators: false,
82
+
83
+ // Extended
84
+ reactions: true,
85
+ multiReactionPerUser: false,
86
+ editMessage: true,
87
+ deleteMessage: true,
88
+ presence: false,
89
+ richBlocks: false,
90
+ interactiveComponents: true,
91
+ inlineImages: false,
92
+ conversationHistory: true,
93
+ contactCards: false,
94
+ locationSharing: false,
95
+ voiceNotes: false,
96
+ stickers: false,
97
+
98
+ // Content format support
99
+ supportedBodyFormats: ['text', 'markdown'],
100
+ maxBodyLength: DISCORD_MAX_BODY_LENGTH,
101
+
102
+ // The gateway worker is the real-time source; the hub must not schedule polling.
103
+ realtimePush: true,
104
+ }
@@ -0,0 +1,110 @@
1
+ import type { AwilixContainer } from 'awilix'
2
+ import type { EntityManager } from '@mikro-orm/postgresql'
3
+ import { NextResponse } from 'next/server'
4
+ import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
5
+ import { resolveOrganizationScopeForRequest } from '@open-mercato/core/modules/directory/utils/organizationScope'
6
+ import { resolveOrganizationScopeFilter } from '@open-mercato/core/modules/directory/utils/organizationScopeFilter'
7
+ import { CommunicationChannel } from '@open-mercato/core/modules/communication_channels/data/entities'
8
+ import {
9
+ assertCanAccessChannel,
10
+ assertCanManageChannel,
11
+ channelOrgScopeWhereFromFilter,
12
+ ChannelAccessDeniedError,
13
+ } from '@open-mercato/core/modules/communication_channels/lib/access-control'
14
+
15
+ type RouteAuth = {
16
+ sub?: string | null
17
+ tenantId?: string | null
18
+ orgId?: string | null
19
+ }
20
+
21
+ type RbacServiceLike = {
22
+ loadAcl: (
23
+ userId: string,
24
+ scope: { tenantId: string | null; organizationId: string | null },
25
+ ) => Promise<{ isSuperAdmin: boolean; features: string[]; organizations: string[] | null }>
26
+ }
27
+
28
+ export type DiscordChannelAccess = {
29
+ channel: CommunicationChannel
30
+ /** Organizations the caller may see, for the command's own scoped re-lookup. */
31
+ organizationIds: string[] | null
32
+ /** Organization the decryption scope is anchored to. */
33
+ rbacOrganizationId: string | null
34
+ }
35
+
36
+ /**
37
+ * Load a Discord channel for a provider-owned route, scoped and authorized the
38
+ * same way the hub's own channel-scoped routes do.
39
+ *
40
+ * Reusing the hub's helpers rather than filtering on `auth.orgId` matters twice
41
+ * over. A Discord bot channel is usually tenant-scoped (`organization_id IS
42
+ * NULL`), so an equality filter on the caller's selected organization would hide
43
+ * exactly the channels this feature configures (#5012 fixed the same bug on the
44
+ * hub's detail route). And a per-user channel belongs to its owner: the shared
45
+ * access guards are what stop one operator from arming an AI on another's
46
+ * mailbox, which no feature grant should be able to override.
47
+ *
48
+ * Returns `{ response }` with a masked 404 when the channel is absent or the
49
+ * caller may not touch it — existence masking, consistent with the hub.
50
+ */
51
+ export async function loadDiscordChannelForRequest(params: {
52
+ container: AwilixContainer
53
+ req: Request
54
+ auth: RouteAuth
55
+ channelId: string
56
+ mode: 'read' | 'manage'
57
+ }): Promise<DiscordChannelAccess | { response: Response }> {
58
+ const { container, req, auth, channelId, mode } = params
59
+ const notFound = { response: NextResponse.json({ error: 'Channel not found' }, { status: 404 }) }
60
+
61
+ const em = (container.resolve('em') as EntityManager).fork()
62
+ const scope = await resolveOrganizationScopeForRequest({ container, auth: auth as never, request: req })
63
+ const orgFilter = resolveOrganizationScopeFilter(scope, auth as never)
64
+
65
+ const channel = await findOneWithDecryption(
66
+ em,
67
+ CommunicationChannel,
68
+ {
69
+ id: channelId,
70
+ tenantId: auth.tenantId as string,
71
+ providerKey: 'discord',
72
+ ...channelOrgScopeWhereFromFilter(orgFilter),
73
+ deletedAt: null,
74
+ },
75
+ undefined,
76
+ { tenantId: auth.tenantId as string, organizationId: orgFilter.rbacOrganizationId },
77
+ )
78
+ if (!channel) return notFound
79
+
80
+ let userFeatures: string[] = []
81
+ try {
82
+ const rbac = container.resolve('rbacService') as RbacServiceLike
83
+ const acl = await rbac.loadAcl(auth.sub as string, {
84
+ tenantId: auth.tenantId as string,
85
+ organizationId: orgFilter.rbacOrganizationId,
86
+ })
87
+ userFeatures = acl.features
88
+ } catch {
89
+ // A failed ACL load leaves `userFeatures` empty, which only makes the guard
90
+ // below stricter — it can deny, never grant.
91
+ userFeatures = []
92
+ }
93
+
94
+ try {
95
+ if (mode === 'manage') {
96
+ assertCanManageChannel(channel, auth.sub ?? null, userFeatures, 'communication_channels.manage')
97
+ } else {
98
+ assertCanAccessChannel(channel, auth.sub ?? null, userFeatures)
99
+ }
100
+ } catch (err) {
101
+ if (err instanceof ChannelAccessDeniedError) return notFound
102
+ throw err
103
+ }
104
+
105
+ return {
106
+ channel,
107
+ organizationIds: orgFilter.organizationIds ?? null,
108
+ rbacOrganizationId: orgFilter.rbacOrganizationId,
109
+ }
110
+ }
@@ -0,0 +1,217 @@
1
+ import type { EntityManager } from '@mikro-orm/postgresql'
2
+ import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
3
+ import { CommunicationChannel } from '@open-mercato/core/modules/communication_channels/data/entities'
4
+ import { discordChannelStateSchema, type DiscordChannelState } from './credentials'
5
+
6
+ /**
7
+ * Tenant/organization scope every Discord channel-state write MUST carry.
8
+ * The gateway worker persists resume state from a WebSocket callback that fires
9
+ * long after the job handler returned, so the scope is captured up front from
10
+ * the channel row the job loaded and replayed on every write.
11
+ */
12
+ export type DiscordChannelScope = {
13
+ tenantId: string
14
+ organizationId: string | null
15
+ }
16
+
17
+ /** The gateway-owned subset of `channelState` this store is allowed to write. */
18
+ export type DiscordChannelStatePatch = {
19
+ sessionId?: string
20
+ sequence?: number | null
21
+ resumeGatewayUrl?: string
22
+ botUserId?: string
23
+ }
24
+
25
+ export type PersistDiscordChannelStateResult = 'written' | 'skipped' | 'not_found'
26
+
27
+ /**
28
+ * Merge a gateway resume-state patch onto the state currently stored on the
29
+ * channel row, or return `null` when the patch must not be written.
30
+ *
31
+ * Two rules make this safe against the socket callbacks racing each other:
32
+ *
33
+ * - Only the gateway-owned keys are touched; every other key (`aiAutoReplyEnabled`,
34
+ * `aiAgentId`, operator-set fields) is carried forward from the row that was
35
+ * just read, so a concurrent UI edit is never clobbered wholesale.
36
+ * - A patch that would rewind the sequence of the session already stored is
37
+ * dropped. A late `READY` callback from a superseded socket must not overwrite
38
+ * fresher resume state with a stale cursor.
39
+ */
40
+ export function mergeDiscordChannelState(
41
+ currentRaw: unknown,
42
+ patch: DiscordChannelStatePatch,
43
+ now: Date,
44
+ ): DiscordChannelState | null {
45
+ const current = discordChannelStateSchema.parse(currentRaw ?? {})
46
+ const sameSession = Boolean(patch.sessionId) && patch.sessionId === current.sessionId
47
+ const rewindsSequence =
48
+ sameSession &&
49
+ typeof patch.sequence === 'number' &&
50
+ typeof current.sequence === 'number' &&
51
+ patch.sequence < current.sequence
52
+ if (rewindsSequence) return null
53
+
54
+ return {
55
+ ...current,
56
+ ...patch,
57
+ lastConnectedAt: now.toISOString(),
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Scoped read-modify-write of a Discord channel's gateway resume state.
63
+ *
64
+ * The lookup filters by `tenant_id` / `organization_id` (never by id alone) so a
65
+ * socket callback can only ever touch the row its own channel scope owns, and it
66
+ * runs on its own `EntityManager` fork so the flush never rides on the request
67
+ * context the worker job was started from.
68
+ */
69
+ export async function persistDiscordChannelState(params: {
70
+ em: EntityManager
71
+ channelId: string
72
+ scope: DiscordChannelScope
73
+ patch: DiscordChannelStatePatch
74
+ now?: Date
75
+ }): Promise<PersistDiscordChannelStateResult> {
76
+ const fork = params.em.fork()
77
+ const channel = await findOneWithDecryption(
78
+ fork,
79
+ CommunicationChannel,
80
+ {
81
+ id: params.channelId,
82
+ tenantId: params.scope.tenantId,
83
+ organizationId: params.scope.organizationId,
84
+ deletedAt: null,
85
+ },
86
+ undefined,
87
+ {
88
+ tenantId: params.scope.tenantId,
89
+ organizationId: params.scope.organizationId ?? params.scope.tenantId,
90
+ },
91
+ )
92
+ if (!channel) return 'not_found'
93
+
94
+ const merged = mergeDiscordChannelState(channel.channelState, params.patch, params.now ?? new Date())
95
+ if (!merged) return 'skipped'
96
+
97
+ channel.channelState = merged
98
+ await fork.flush()
99
+ return 'written'
100
+ }
101
+
102
+ export type RecordAutoReplyOutcomeResult = 'written' | 'unchanged' | 'not_found'
103
+
104
+ /** Truncated so a provider stack trace cannot grow the JSONB column without bound. */
105
+ const AUTO_REPLY_ERROR_MAX_LENGTH = 500
106
+
107
+ /**
108
+ * Record — or clear — why the last AI auto-reply attempt on this channel produced
109
+ * nothing.
110
+ *
111
+ * The subscriber degrades every failure to a no-op on purpose: a broken model, a
112
+ * denied policy check or a malformed object must never turn into a send. Being
113
+ * *silent* about it is the part that hurts, because the operator's surface still
114
+ * reads "Auto-reply on" while the channel answers nothing, and a `logger.warn` in
115
+ * a background subscriber is not somewhere anyone looks. A save-time check cannot
116
+ * close this on its own either — the settings route validates the agent against
117
+ * the auto-reply principal, but a role edited afterwards makes that verdict stale.
118
+ *
119
+ * `failure: null` clears the marker, and the caller is expected to skip the write
120
+ * when there is nothing to clear — a successful reply on a healthy channel must
121
+ * not cost a row update per inbound message.
122
+ *
123
+ * The channel row's own `lastError` is deliberately left alone: the hub owns it
124
+ * for delivery and polling failures, clears it on a successful poll, and keys the
125
+ * reauth banner off `status`. Borrowing it here would let an AI failure mask a
126
+ * delivery failure and let a successful poll erase an AI one.
127
+ */
128
+ export async function recordDiscordAutoReplyOutcome(params: {
129
+ em: EntityManager
130
+ channelId: string
131
+ scope: DiscordChannelScope
132
+ failure: string | null
133
+ now?: Date
134
+ }): Promise<RecordAutoReplyOutcomeResult> {
135
+ const fork = params.em.fork()
136
+ const channel = await findOneWithDecryption(
137
+ fork,
138
+ CommunicationChannel,
139
+ {
140
+ id: params.channelId,
141
+ tenantId: params.scope.tenantId,
142
+ organizationId: params.scope.organizationId,
143
+ deletedAt: null,
144
+ },
145
+ undefined,
146
+ {
147
+ tenantId: params.scope.tenantId,
148
+ organizationId: params.scope.organizationId ?? params.scope.tenantId,
149
+ },
150
+ )
151
+ if (!channel) return 'not_found'
152
+
153
+ const current = discordChannelStateSchema.parse(channel.channelState ?? {})
154
+ const failure = params.failure?.slice(0, AUTO_REPLY_ERROR_MAX_LENGTH) ?? null
155
+ if (failure === null && current.aiAutoReplyLastError === undefined) return 'unchanged'
156
+ if (failure !== null && current.aiAutoReplyLastError === failure) return 'unchanged'
157
+
158
+ const next: DiscordChannelState = { ...current }
159
+ if (failure === null) {
160
+ delete next.aiAutoReplyLastError
161
+ delete next.aiAutoReplyLastErrorAt
162
+ } else {
163
+ next.aiAutoReplyLastError = failure
164
+ next.aiAutoReplyLastErrorAt = (params.now ?? new Date()).toISOString()
165
+ }
166
+
167
+ channel.channelState = next
168
+ await fork.flush()
169
+ return 'written'
170
+ }
171
+
172
+ export type QuarantineDiscordChannelResult = 'quarantined' | 'not_found'
173
+
174
+ /**
175
+ * Park a channel Discord has fatally rejected so the reconciler stops re-opening it.
176
+ *
177
+ * A `4004` (invalid token) or `4014` (disallowed intents) close is not
178
+ * recoverable by retrying: the token or the Developer Portal toggle has to
179
+ * change first. Without a persisted status the refresh job sees "no live
180
+ * session" on the next tick and IDENTIFYs again — at the CLI's default 60s
181
+ * refresh that is ~1440 session starts per day against Discord's ~1000/day
182
+ * per-bot budget, while the row and the admin UI still read `connected`.
183
+ *
184
+ * Writing `requires_reauth` mirrors what the hub already does for a channel
185
+ * whose credentials could not be persisted (`connect-channel.ts`), so the
186
+ * existing reauth banner, the mutation guard that keys on `status`, and the
187
+ * operator's reconnect flow all engage without any hub change.
188
+ */
189
+ export async function quarantineDiscordChannel(params: {
190
+ em: EntityManager
191
+ channelId: string
192
+ scope: DiscordChannelScope
193
+ reason: string
194
+ }): Promise<QuarantineDiscordChannelResult> {
195
+ const fork = params.em.fork()
196
+ const channel = await findOneWithDecryption(
197
+ fork,
198
+ CommunicationChannel,
199
+ {
200
+ id: params.channelId,
201
+ tenantId: params.scope.tenantId,
202
+ organizationId: params.scope.organizationId,
203
+ deletedAt: null,
204
+ },
205
+ undefined,
206
+ {
207
+ tenantId: params.scope.tenantId,
208
+ organizationId: params.scope.organizationId ?? params.scope.tenantId,
209
+ },
210
+ )
211
+ if (!channel) return 'not_found'
212
+
213
+ channel.status = 'requires_reauth'
214
+ channel.lastError = params.reason
215
+ await fork.flush()
216
+ return 'quarantined'
217
+ }
@@ -0,0 +1,88 @@
1
+ import type {
2
+ ChannelNativeContent,
3
+ ConvertOutboundInput,
4
+ } from '@open-mercato/core/modules/communication_channels/lib/adapter'
5
+ import { createLogger } from '@open-mercato/shared/lib/logger'
6
+ import { DISCORD_MAX_BODY_LENGTH } from './capabilities'
7
+
8
+ const logger = createLogger('channel_discord').child({ component: 'convert-outbound' })
9
+
10
+ /**
11
+ * Very small HTML → markdown down-converter for the common inline tags the hub's
12
+ * `html` body format produces. Discord content is markdown-native, so we map the
13
+ * handful of tags that have a markdown equivalent and strip the rest rather than
14
+ * pulling in a full HTML parser dependency.
15
+ */
16
+ function htmlToMarkdown(html: string): string {
17
+ return html
18
+ .replace(/<\s*br\s*\/?\s*>/gi, '\n')
19
+ .replace(/<\s*\/\s*p\s*>/gi, '\n\n')
20
+ .replace(/<\s*p[^>]*>/gi, '')
21
+ .replace(/<\s*(strong|b)\s*>(.*?)<\s*\/\s*\1\s*>/gis, '**$2**')
22
+ .replace(/<\s*(em|i)\s*>(.*?)<\s*\/\s*\1\s*>/gis, '*$2*')
23
+ .replace(/<\s*code\s*>(.*?)<\s*\/\s*code\s*>/gis, '`$1`')
24
+ .replace(/<\s*a[^>]*href=["']([^"']+)["'][^>]*>(.*?)<\s*\/\s*a\s*>/gis, '[$2]($1)')
25
+ .replace(/<[^>]+>/g, '')
26
+ .replace(/&nbsp;/gi, ' ')
27
+ .replace(/&amp;/gi, '&')
28
+ .replace(/&lt;/gi, '<')
29
+ .replace(/&gt;/gi, '>')
30
+ .replace(/&quot;/gi, '"')
31
+ .replace(/\n{3,}/g, '\n\n')
32
+ .trim()
33
+ }
34
+
35
+ /**
36
+ * Convert the hub's normalized outbound body into Discord native content.
37
+ *
38
+ * - `markdown` / `text` pass through unchanged (Discord is markdown-native).
39
+ * - `html` is down-converted to markdown.
40
+ * - Content is clamped to Discord's 2000-char hard limit (a longer body is
41
+ * truncated with an ellipsis marker rather than rejected by the API).
42
+ * - `allowed_mentions` defaults to `{ parse: [] }` so an AI/automated reply can
43
+ * never accidentally @-ping everyone; callers can widen it via `channelMetadata`.
44
+ * - Attachments are NOT uploaded: `discord-rest` has no multipart upload, so the
45
+ * capability profile declares `fileSharing: false` and the hub should never
46
+ * route an attachment here. If one arrives anyway we log it rather than drop
47
+ * it silently, and report the count in `metadata.droppedAttachmentCount` so the
48
+ * caller can surface the gap.
49
+ */
50
+ export async function convertOutboundForDiscord(input: ConvertOutboundInput): Promise<ChannelNativeContent> {
51
+ const droppedAttachmentCount = input.attachments?.length ?? 0
52
+ if (droppedAttachmentCount > 0) {
53
+ logger.warn('discord outbound attachments are not supported — sending text only', {
54
+ droppedAttachmentCount,
55
+ })
56
+ }
57
+
58
+ const raw = input.body ?? ''
59
+ const markdown = input.bodyFormat === 'html' ? htmlToMarkdown(raw) : raw
60
+
61
+ const TRUNCATE_MARKER = '…'
62
+ const content =
63
+ markdown.length > DISCORD_MAX_BODY_LENGTH
64
+ ? markdown.slice(0, DISCORD_MAX_BODY_LENGTH - TRUNCATE_MARKER.length) + TRUNCATE_MARKER
65
+ : markdown
66
+
67
+ const allowedMentions =
68
+ (input.channelMetadata?.allowedMentions as Record<string, unknown> | undefined) ?? { parse: [] }
69
+
70
+ return {
71
+ content: {
72
+ text: content,
73
+ bodyFormat: 'markdown',
74
+ },
75
+ metadata: {
76
+ allowedMentions,
77
+ droppedAttachmentCount,
78
+ // Reply threading, dormant: no hub producer writes `replyToExternalId`
79
+ // into outbound metadata today, which is why `capabilities.threading` is
80
+ // declared `false`. Kept so that flag flips back with the hub-side
81
+ // producer and nothing else.
82
+ messageReferenceId:
83
+ typeof input.channelMetadata?.replyToExternalId === 'string'
84
+ ? (input.channelMetadata.replyToExternalId as string)
85
+ : undefined,
86
+ },
87
+ }
88
+ }
@@ -0,0 +1,88 @@
1
+ import { z } from 'zod'
2
+
3
+ /**
4
+ * Discord bot channel credentials (SPEC 2026-06-19 § Data models).
5
+ *
6
+ * The hub persists this blob inside `IntegrationCredentials.credentials`
7
+ * (encrypted at rest, scope `channel_discord`). Never log any value — the bot
8
+ * token grants full control of the bot user.
9
+ *
10
+ * `.passthrough()` (not `.strict()`) so the connect-credential-channel command
11
+ * can stash bookkeeping fields (e.g. `userId`) alongside the entered credentials,
12
+ * mirroring the IMAP provider.
13
+ */
14
+ export const discordCredentialsSchema = z
15
+ .object({
16
+ // "Bot <token>" — used as `Authorization: Bot <token>` on every REST call and
17
+ // for the gateway Identify handshake. Never logged.
18
+ botToken: z.string().min(1, 'Bot token required'),
19
+ // Application (client) id — needed to register slash commands + build invites.
20
+ applicationId: z.string().min(1, 'Application ID required'),
21
+ // Ed25519 public key (hex) from the application's General Information tab —
22
+ // verifies signed interaction requests. Never used as a secret; safe to store.
23
+ publicKey: z
24
+ .string()
25
+ .min(1, 'Public key required')
26
+ .regex(/^[0-9a-fA-F]+$/, 'Public key must be hex')
27
+ .refine((value) => value.length === 64, 'Public key must be a 32-byte (64 hex char) Ed25519 key'),
28
+ // Scope the bot to one guild (recommended). Optional.
29
+ guildId: z.string().optional(),
30
+ // Default outbound text channel id (used by the test-send smoke test).
31
+ defaultChannelId: z.string().optional(),
32
+ })
33
+ .passthrough()
34
+
35
+ export type DiscordCredentials = z.infer<typeof discordCredentialsSchema>
36
+
37
+ /**
38
+ * Gateway resume state persisted on `CommunicationChannel.channelState` (JSONB,
39
+ * additive) so the worker can `RESUME` instead of re-`IDENTIFY` after a
40
+ * disconnect. Discord requires the stored `resumeGatewayUrl` + `sessionId` +
41
+ * last `sequence` to resume a session.
42
+ */
43
+ export const discordChannelStateSchema = z
44
+ .object({
45
+ sessionId: z.string().optional(),
46
+ sequence: z.number().nullable().optional(),
47
+ resumeGatewayUrl: z.string().optional(),
48
+ // Bot's own user id — cached after the READY event so the worker can drop
49
+ // events it authored (feedback-loop guard) without an extra REST call.
50
+ botUserId: z.string().optional(),
51
+ lastConnectedAt: z.string().optional(),
52
+ // Per-channel AI auto-reply toggle (default OFF). When truthy, the AI
53
+ // auto-reply subscriber may answer "easy" inbound messages and proposes a
54
+ // reply for everything else. Written by the channel's AI auto-reply settings
55
+ // form via `PUT /api/channel_discord/channels/{id}/ai-auto-reply`.
56
+ aiAutoReplyEnabled: z.boolean().optional(),
57
+ // Object-mode agent id invoked when auto-reply is enabled. Defaults to the
58
+ // provider's own `channel_discord.auto_reply` agent; a tenant may point it at
59
+ // any object-mode agent whose `requiredFeatures` its channel-bot user holds.
60
+ aiAgentId: z.string().optional(),
61
+ // Why the last auto-reply attempt produced nothing, cleared by the next
62
+ // attempt that gets somewhere. The settings route validates a channel's agent
63
+ // against the auto-reply principal before arming it, but a role edit after the
64
+ // fact can still make the runtime start denying the call — and the subscriber
65
+ // degrades to a no-op by design, because a broken model must never become a
66
+ // send. Without a persisted marker that leaves an "Auto-reply on" channel
67
+ // answering nothing with nothing for an operator to look at. Written only by
68
+ // `lib/channel-state-store.ts`, never by the subscriber directly.
69
+ aiAutoReplyLastError: z.string().optional(),
70
+ // When the *current* failure was first observed. A channel that keeps failing
71
+ // for the same reason does not rewrite the row on every inbound message, so
72
+ // this is a first-seen stamp rather than a last-attempt one — the alternative
73
+ // is a row update per message on exactly the channels already misbehaving.
74
+ aiAutoReplyLastErrorAt: z.string().optional(),
75
+ })
76
+ .partial()
77
+ .passthrough()
78
+
79
+ export type DiscordChannelState = z.infer<typeof discordChannelStateSchema>
80
+
81
+ export function parseDiscordCredentialsOrThrow(value: unknown): DiscordCredentials {
82
+ const parsed = discordCredentialsSchema.safeParse(value)
83
+ if (!parsed.success) {
84
+ const first = parsed.error.issues[0]
85
+ throw new Error(`[internal] Invalid Discord credentials: ${first?.message ?? 'unknown validation error'}`)
86
+ }
87
+ return parsed.data
88
+ }