@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,393 @@
1
+ /**
2
+ * Thin Discord REST API client. Same trade-off as the Gmail provider's
3
+ * `gmail-client.ts`: we call `fetch` directly (no `discord.js`) so the adapter
4
+ * ships zero heavyweight runtime dependencies and stays swappable in tests via
5
+ * `setDiscordRestClient(...)`.
6
+ *
7
+ * Only the endpoints the adapter actually uses are exposed:
8
+ * - createMessage → POST /channels/{id}/messages
9
+ * - editMessage → PATCH /channels/{id}/messages/{mid}
10
+ * - deleteMessage → DELETE /channels/{id}/messages/{mid}
11
+ * - addReaction → PUT /channels/{id}/messages/{mid}/reactions/{emoji}/@me
12
+ * - removeReaction → DELETE /channels/{id}/messages/{mid}/reactions/{emoji}/@me
13
+ * - listMessages → GET /channels/{id}/messages?before=
14
+ * - getCurrentUser → GET /users/@me
15
+ * - getGatewayBot → GET /gateway/bot
16
+ * - registerGuildCommands → PUT /applications/{app}/guilds/{guild}/commands
17
+ * - editOriginalInteractionResponse → PATCH /webhooks/{app}/{token}/messages/@original
18
+ * - createInteractionFollowUp → POST /webhooks/{app}/{token}
19
+ */
20
+ import { fetchWithTimeout, FetchTimeoutError } from '@open-mercato/shared/lib/http/fetchWithTimeout'
21
+
22
+ export const DISCORD_API_BASE = 'https://discord.com/api/v10'
23
+
24
+ export interface DiscordAuth {
25
+ botToken: string
26
+ }
27
+
28
+ export interface DiscordUser {
29
+ id: string
30
+ username: string
31
+ global_name?: string | null
32
+ bot?: boolean
33
+ discriminator?: string
34
+ avatar?: string | null
35
+ }
36
+
37
+ /**
38
+ * Open Mercato marker carried by a message object that was SYNTHESIZED from a
39
+ * Discord interaction rather than received from Discord. Discord never returns
40
+ * this key; it exists so `normalizeInboundDiscordMessage` can tell a hub
41
+ * consumer that the record came from a slash command or a component press
42
+ * instead of someone typing in the channel.
43
+ */
44
+ export interface DiscordInteractionOrigin {
45
+ id: string
46
+ type: number
47
+ applicationId: string
48
+ commandName?: string
49
+ customId?: string
50
+ }
51
+
52
+ export interface DiscordMessageObject {
53
+ id: string
54
+ channel_id: string
55
+ guild_id?: string
56
+ content: string
57
+ author: DiscordUser
58
+ timestamp: string
59
+ attachments?: Array<{ id: string; url: string; filename: string; content_type?: string; size?: number }>
60
+ message_reference?: { message_id?: string; channel_id?: string; guild_id?: string }
61
+ /** Present only on interaction-derived messages — see `DiscordInteractionOrigin`. */
62
+ discord_interaction?: DiscordInteractionOrigin
63
+ [key: string]: unknown
64
+ }
65
+
66
+ export interface CreateMessageInput {
67
+ channelId: string
68
+ content: string
69
+ messageReferenceId?: string
70
+ allowedMentions?: Record<string, unknown>
71
+ }
72
+
73
+ /**
74
+ * Body of a message that replaces (or follows) a deferred interaction response.
75
+ *
76
+ * `ephemeral` maps to Discord's `flags: 64` — only the invoking user sees the
77
+ * message. It is the right default for an operational acknowledgement that would
78
+ * otherwise be noise for everyone else in the channel.
79
+ */
80
+ export interface InteractionResponseInput {
81
+ content: string
82
+ ephemeral?: boolean
83
+ }
84
+
85
+ /** Discord message flag: visible only to the user who triggered the interaction. */
86
+ export const DISCORD_MESSAGE_FLAG_EPHEMERAL = 64
87
+
88
+ export interface GatewayBotResponse {
89
+ url: string
90
+ shards: number
91
+ session_start_limit?: { total: number; remaining: number; reset_after: number; max_concurrency: number }
92
+ }
93
+
94
+ export interface DiscordRestClient {
95
+ createMessage(auth: DiscordAuth, input: CreateMessageInput): Promise<DiscordMessageObject>
96
+ editMessage(auth: DiscordAuth, channelId: string, messageId: string, content: string): Promise<DiscordMessageObject>
97
+ deleteMessage(auth: DiscordAuth, channelId: string, messageId: string): Promise<void>
98
+ addReaction(auth: DiscordAuth, channelId: string, messageId: string, emoji: string): Promise<void>
99
+ removeReaction(auth: DiscordAuth, channelId: string, messageId: string, emoji: string): Promise<void>
100
+ listMessages(
101
+ auth: DiscordAuth,
102
+ channelId: string,
103
+ input: { before?: string; limit?: number },
104
+ ): Promise<DiscordMessageObject[]>
105
+ getCurrentUser(auth: DiscordAuth): Promise<DiscordUser>
106
+ getGatewayBot(auth: DiscordAuth): Promise<GatewayBotResponse>
107
+ registerGuildCommands(
108
+ auth: DiscordAuth,
109
+ applicationId: string,
110
+ guildId: string,
111
+ commands: Array<Record<string, unknown>>,
112
+ ): Promise<void>
113
+ /**
114
+ * Replace the "thinking…" placeholder a deferred interaction response left
115
+ * behind. This is the call that ends the pending state, so it is the one the
116
+ * dispatch worker makes first.
117
+ *
118
+ * Takes NO bot token: the interaction token in the URL is the credential, and
119
+ * Discord documents these webhook endpoints as requiring no authorization. The
120
+ * token is single-interaction and expires after 15 minutes.
121
+ */
122
+ editOriginalInteractionResponse(
123
+ applicationId: string,
124
+ interactionToken: string,
125
+ input: InteractionResponseInput,
126
+ ): Promise<void>
127
+ /**
128
+ * Post an ADDITIONAL message against the same interaction token. Used as the
129
+ * fallback when the original response can no longer be edited.
130
+ */
131
+ createInteractionFollowUp(
132
+ applicationId: string,
133
+ interactionToken: string,
134
+ input: InteractionResponseInput,
135
+ ): Promise<void>
136
+ }
137
+
138
+ export class DiscordApiError extends Error {
139
+ readonly status: number
140
+ readonly detail: string
141
+ constructor(message: string, status: number, detail: string) {
142
+ super(message)
143
+ this.name = 'DiscordApiError'
144
+ this.status = status
145
+ this.detail = detail
146
+ }
147
+ }
148
+
149
+ const DISCORD_MAX_RETRIES = 3
150
+ const DISCORD_BACKOFF_BASE_MS = 500
151
+ const DISCORD_BACKOFF_CAP_MS = 8_000
152
+ const DISCORD_DEFAULT_REQUEST_TIMEOUT_MS = 15_000
153
+
154
+ function resolveRequestTimeoutMs(): number {
155
+ const fromEnv = Number.parseInt(process.env.OM_CHANNEL_DISCORD_REQUEST_TIMEOUT_MS ?? '', 10)
156
+ return Number.isFinite(fromEnv) && fromEnv > 0 ? fromEnv : DISCORD_DEFAULT_REQUEST_TIMEOUT_MS
157
+ }
158
+
159
+ /**
160
+ * Discord emoji path encoding: unicode emoji is URL-encoded as-is; a custom
161
+ * emoji uses the `name:id` form. We pass through the caller's value and only
162
+ * `encodeURIComponent` it so both forms survive.
163
+ */
164
+ function encodeEmoji(emoji: string): string {
165
+ return encodeURIComponent(emoji)
166
+ }
167
+
168
+ class FetchDiscordRestClient implements DiscordRestClient {
169
+ async createMessage(auth: DiscordAuth, input: CreateMessageInput): Promise<DiscordMessageObject> {
170
+ const body: Record<string, unknown> = {
171
+ content: input.content,
172
+ allowed_mentions: input.allowedMentions ?? { parse: [] },
173
+ }
174
+ if (input.messageReferenceId) {
175
+ body.message_reference = { message_id: input.messageReferenceId }
176
+ }
177
+ return this.request<DiscordMessageObject>(
178
+ auth,
179
+ 'POST',
180
+ `/channels/${encodeURIComponent(input.channelId)}/messages`,
181
+ body,
182
+ )
183
+ }
184
+
185
+ async editMessage(
186
+ auth: DiscordAuth,
187
+ channelId: string,
188
+ messageId: string,
189
+ content: string,
190
+ ): Promise<DiscordMessageObject> {
191
+ return this.request<DiscordMessageObject>(
192
+ auth,
193
+ 'PATCH',
194
+ `/channels/${encodeURIComponent(channelId)}/messages/${encodeURIComponent(messageId)}`,
195
+ { content },
196
+ )
197
+ }
198
+
199
+ async deleteMessage(auth: DiscordAuth, channelId: string, messageId: string): Promise<void> {
200
+ await this.request<void>(
201
+ auth,
202
+ 'DELETE',
203
+ `/channels/${encodeURIComponent(channelId)}/messages/${encodeURIComponent(messageId)}`,
204
+ )
205
+ }
206
+
207
+ async addReaction(auth: DiscordAuth, channelId: string, messageId: string, emoji: string): Promise<void> {
208
+ await this.request<void>(
209
+ auth,
210
+ 'PUT',
211
+ `/channels/${encodeURIComponent(channelId)}/messages/${encodeURIComponent(messageId)}/reactions/${encodeEmoji(emoji)}/@me`,
212
+ )
213
+ }
214
+
215
+ async removeReaction(auth: DiscordAuth, channelId: string, messageId: string, emoji: string): Promise<void> {
216
+ await this.request<void>(
217
+ auth,
218
+ 'DELETE',
219
+ `/channels/${encodeURIComponent(channelId)}/messages/${encodeURIComponent(messageId)}/reactions/${encodeEmoji(emoji)}/@me`,
220
+ )
221
+ }
222
+
223
+ async listMessages(
224
+ auth: DiscordAuth,
225
+ channelId: string,
226
+ input: { before?: string; limit?: number },
227
+ ): Promise<DiscordMessageObject[]> {
228
+ const search = new URLSearchParams()
229
+ if (input.before) search.set('before', input.before)
230
+ search.set('limit', String(Math.min(Math.max(input.limit ?? 50, 1), 100)))
231
+ const query = search.toString()
232
+ return this.request<DiscordMessageObject[]>(
233
+ auth,
234
+ 'GET',
235
+ `/channels/${encodeURIComponent(channelId)}/messages?${query}`,
236
+ )
237
+ }
238
+
239
+ async getCurrentUser(auth: DiscordAuth): Promise<DiscordUser> {
240
+ return this.request<DiscordUser>(auth, 'GET', '/users/@me')
241
+ }
242
+
243
+ async getGatewayBot(auth: DiscordAuth): Promise<GatewayBotResponse> {
244
+ return this.request<GatewayBotResponse>(auth, 'GET', '/gateway/bot')
245
+ }
246
+
247
+ async registerGuildCommands(
248
+ auth: DiscordAuth,
249
+ applicationId: string,
250
+ guildId: string,
251
+ commands: Array<Record<string, unknown>>,
252
+ ): Promise<void> {
253
+ await this.request<unknown>(
254
+ auth,
255
+ 'PUT',
256
+ `/applications/${encodeURIComponent(applicationId)}/guilds/${encodeURIComponent(guildId)}/commands`,
257
+ commands,
258
+ )
259
+ }
260
+
261
+ async editOriginalInteractionResponse(
262
+ applicationId: string,
263
+ interactionToken: string,
264
+ input: InteractionResponseInput,
265
+ ): Promise<void> {
266
+ await this.request<unknown>(
267
+ null,
268
+ 'PATCH',
269
+ `/webhooks/${encodeURIComponent(applicationId)}/${encodeURIComponent(interactionToken)}/messages/@original`,
270
+ interactionResponseBody(input),
271
+ )
272
+ }
273
+
274
+ async createInteractionFollowUp(
275
+ applicationId: string,
276
+ interactionToken: string,
277
+ input: InteractionResponseInput,
278
+ ): Promise<void> {
279
+ await this.request<unknown>(
280
+ null,
281
+ 'POST',
282
+ `/webhooks/${encodeURIComponent(applicationId)}/${encodeURIComponent(interactionToken)}`,
283
+ interactionResponseBody(input),
284
+ )
285
+ }
286
+
287
+ private async request<T>(
288
+ auth: DiscordAuth | null,
289
+ method: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE',
290
+ path: string,
291
+ body?: unknown,
292
+ ): Promise<T> {
293
+ const url = `${DISCORD_API_BASE}${path}`
294
+ // `auth === null` is the interaction-webhook family: the token in the path is
295
+ // the credential and Discord rejects nothing for the missing header, so
296
+ // sending the bot token there would leak it for no benefit.
297
+ const headers: Record<string, string> = auth ? { Authorization: `Bot ${auth.botToken}` } : {}
298
+ let payload: BodyInit | undefined
299
+ if (body !== undefined) {
300
+ headers['Content-Type'] = 'application/json'
301
+ payload = JSON.stringify(body)
302
+ }
303
+
304
+ let attempt = 0
305
+ let lastError: DiscordApiError | null = null
306
+ while (attempt <= DISCORD_MAX_RETRIES) {
307
+ let res: Response
308
+ try {
309
+ res = await fetchWithTimeout(url, { method, headers, body: payload, timeoutMs: resolveRequestTimeoutMs() })
310
+ } catch (err) {
311
+ const errName = (err as { name?: unknown } | null)?.name
312
+ const aborted = err instanceof FetchTimeoutError || errName === 'TimeoutError' || errName === 'AbortError'
313
+ if (!aborted) throw err
314
+ const timeoutError = new DiscordApiError(`Discord API ${method} ${path} timed out`, 599, 'request timed out')
315
+ if (attempt === DISCORD_MAX_RETRIES) throw timeoutError
316
+ lastError = timeoutError
317
+ await sleep(computeBackoff(attempt))
318
+ attempt += 1
319
+ continue
320
+ }
321
+
322
+ const text = await res.text()
323
+ if (res.ok) {
324
+ if (!text) return undefined as unknown as T
325
+ return JSON.parse(text) as T
326
+ }
327
+
328
+ const detail = parseErrorMessage(text) ?? `${res.status} ${res.statusText}`
329
+ const apiError = new DiscordApiError(`Discord API ${method} ${path} failed: ${detail}`, res.status, detail)
330
+ const transient = res.status === 429 || (res.status >= 500 && res.status < 600)
331
+ if (!transient || attempt === DISCORD_MAX_RETRIES) {
332
+ throw apiError
333
+ }
334
+ lastError = apiError
335
+ const waitMs = parseRetryAfter(res.headers.get('retry-after')) ?? computeBackoff(attempt)
336
+ await sleep(waitMs)
337
+ attempt += 1
338
+ }
339
+ throw lastError ?? new DiscordApiError(`Discord API ${method} ${path} exhausted retries`, 599, 'retries exhausted')
340
+ }
341
+ }
342
+
343
+ function interactionResponseBody(input: InteractionResponseInput): Record<string, unknown> {
344
+ return {
345
+ content: input.content,
346
+ // Interaction replies quote whatever the user typed, so mentions inside it
347
+ // must not become live pings — same guard `createMessage` applies.
348
+ allowed_mentions: { parse: [] },
349
+ ...(input.ephemeral ? { flags: DISCORD_MESSAGE_FLAG_EPHEMERAL } : {}),
350
+ }
351
+ }
352
+
353
+ function parseRetryAfter(value: string | null): number | null {
354
+ if (!value) return null
355
+ const asNumber = Number(value)
356
+ if (Number.isFinite(asNumber) && asNumber >= 0) {
357
+ return Math.min(asNumber * 1000, DISCORD_BACKOFF_CAP_MS)
358
+ }
359
+ return null
360
+ }
361
+
362
+ function computeBackoff(attempt: number): number {
363
+ const raw = DISCORD_BACKOFF_BASE_MS * Math.pow(2, attempt)
364
+ const jitter = Math.floor(Math.random() * 100)
365
+ return Math.min(raw + jitter, DISCORD_BACKOFF_CAP_MS)
366
+ }
367
+
368
+ function sleep(ms: number): Promise<void> {
369
+ return new Promise((resolve) => setTimeout(resolve, ms))
370
+ }
371
+
372
+ function parseErrorMessage(text: string): string | null {
373
+ if (!text) return null
374
+ try {
375
+ const parsed = JSON.parse(text) as { message?: string; error?: string }
376
+ if (typeof parsed?.message === 'string') return parsed.message
377
+ if (typeof parsed?.error === 'string') return parsed.error
378
+ } catch {
379
+ /* fall through */
380
+ }
381
+ return text.length > 200 ? text.slice(0, 200) : text
382
+ }
383
+
384
+ let cachedClient: DiscordRestClient | null = null
385
+
386
+ export function getDiscordRestClient(): DiscordRestClient {
387
+ if (!cachedClient) cachedClient = new FetchDiscordRestClient()
388
+ return cachedClient
389
+ }
390
+
391
+ export function setDiscordRestClient(client: DiscordRestClient | null): void {
392
+ cachedClient = client
393
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Turn an arbitrary thrown value into a one-line reason that is safe to persist
3
+ * and render.
4
+ *
5
+ * The AI auto-reply marker stores this on `channelState` and the settings page
6
+ * renders it, so the string leaves the log and enters the product. An upstream
7
+ * provider error is not a curated message: an HTTP client that echoes the failing
8
+ * URL, or an SDK that quotes the Authorization header, would put a live
9
+ * credential in a JSONB column and on an operator's screen. Redacting is cheap;
10
+ * discovering later that a bot token is sitting in `channel_state` is not.
11
+ *
12
+ * The patterns are deliberately broad-but-anchored — a false positive costs one
13
+ * unreadable fragment of a diagnostic, a false negative costs a leaked secret.
14
+ */
15
+ const SECRET_PATTERNS: readonly RegExp[] = [
16
+ // `Authorization: Bot <token>` / `Bearer <token>`, however it is spelled.
17
+ /\b(?:Bot|Bearer|Basic|token|api[_-]?key|secret|password)\b\s*[:=]?\s*['"]?[A-Za-z0-9._~+/=-]{8,}['"]?/gi,
18
+ // Discord bot tokens and JWT-shaped values: three dot-separated base64url runs.
19
+ /\b[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]{20,}\b/g,
20
+ // Provider API keys that announce themselves with a prefix.
21
+ /\b(?:sk|pk|rk)-[A-Za-z0-9_-]{16,}\b/g,
22
+ // Anything carried in a query string, which is where URLs hide credentials.
23
+ /([?&](?:access_token|token|key|api[_-]?key|secret|signature|sig)=)[^&\s]+/gi,
24
+ ]
25
+
26
+ const REDACTED = '[redacted]'
27
+
28
+ export function redactSecrets(text: string): string {
29
+ return SECRET_PATTERNS.reduce(
30
+ // Only the query-string pattern captures a group. `String.replace` passes the
31
+ // match offset in that slot for the patterns that do not, so the type check is
32
+ // load-bearing — without it a redacted value comes back stamped with a number.
33
+ (current, pattern) => current.replace(pattern, (_match, group: unknown) => (
34
+ typeof group === 'string' ? `${group}${REDACTED}` : REDACTED
35
+ )),
36
+ text,
37
+ )
38
+ }
39
+
40
+ /**
41
+ * A one-line, operator-readable reason naming the agent that failed — which is
42
+ * the first thing someone staring at a silent channel needs to know — with the
43
+ * upstream message redacted and its stack left behind.
44
+ */
45
+ export function describeAgentFailure(agentId: string, err: unknown): string {
46
+ const raw = err instanceof Error ? err.message : String(err)
47
+ const firstLine = raw.split('\n', 1)[0]?.trim() || 'unknown error'
48
+ return `agent ${agentId}: ${redactSecrets(firstLine)}`
49
+ }
@@ -0,0 +1,79 @@
1
+ import type { InboundMessage } from '@open-mercato/core/modules/communication_channels/lib/adapter'
2
+ import type { InboundProcessorPayload } from '@open-mercato/core/modules/communication_channels/workers/inbound-processor'
3
+ import type { ReactionInboundJob } from '@open-mercato/core/modules/communication_channels/lib/reaction-processor-types'
4
+ import type { DiscordMessageObject } from './discord-rest'
5
+ import { getDiscordChannelAdapter } from './adapter'
6
+ import { isAuthoredByBot } from './normalize-inbound'
7
+ import { DISCORD_CONVERSATION_PREFIX } from './normalize-inbound'
8
+
9
+ export interface GatewayChannelScope {
10
+ channelId: string
11
+ channelType: string
12
+ tenantId: string
13
+ organizationId: string | null
14
+ }
15
+
16
+ /**
17
+ * Build the hub inbound-processor job for a `MESSAGE_CREATE` gateway event.
18
+ *
19
+ * Returns `null` when the message was authored by the bot itself — this is the
20
+ * feedback-loop guard the spec requires (the bot must never ingest + answer its
21
+ * own outbound messages). The hub then dedups by `(channel_id, external_message_id)`.
22
+ *
23
+ * The raw Discord object is wrapped as an `InboundMessage` so the existing
24
+ * `inbound-processor` worker calls `adapter.normalizeInbound` exactly as it does
25
+ * for the webhook path — no hub change.
26
+ */
27
+ export function buildInboundMessageJob(input: {
28
+ message: DiscordMessageObject
29
+ channel: GatewayChannelScope
30
+ botUserId: string | undefined
31
+ }): InboundProcessorPayload | null {
32
+ const { message, channel, botUserId } = input
33
+ if (isAuthoredByBot(message, botUserId)) return null
34
+
35
+ const raw: InboundMessage = {
36
+ raw: { discordMessage: message as unknown as Record<string, unknown> },
37
+ eventType: 'message',
38
+ }
39
+ return {
40
+ providerKey: 'discord',
41
+ channelId: channel.channelId,
42
+ channelType: channel.channelType,
43
+ raw,
44
+ scope: { tenantId: channel.tenantId, organizationId: channel.organizationId },
45
+ }
46
+ }
47
+
48
+ /**
49
+ * Build the hub reaction-processor job for a `MESSAGE_REACTION_ADD/REMOVE`
50
+ * gateway event. Returns `null` for reactions the bot itself added. The reaction
51
+ * is normalized through the adapter so it matches the webhook reaction path.
52
+ */
53
+ export async function buildReactionJob(input: {
54
+ reaction: Record<string, unknown>
55
+ action: 'added' | 'removed'
56
+ channel: GatewayChannelScope
57
+ botUserId: string | undefined
58
+ }): Promise<ReactionInboundJob | null> {
59
+ const { reaction, action, channel, botUserId } = input
60
+ const reactionUserId = typeof reaction.user_id === 'string' ? reaction.user_id : undefined
61
+ if (botUserId && reactionUserId === botUserId) return null
62
+
63
+ const event = await getDiscordChannelAdapter().normalizeInboundReaction({
64
+ raw: { discordReaction: { ...reaction, action } },
65
+ eventType: 'reaction',
66
+ })
67
+
68
+ return {
69
+ kind: 'inbound',
70
+ providerKey: 'discord',
71
+ channelId: channel.channelId,
72
+ channelType: channel.channelType,
73
+ event,
74
+ scope: { tenantId: channel.tenantId, organizationId: channel.organizationId },
75
+ attempt: 1,
76
+ }
77
+ }
78
+
79
+ export { DISCORD_CONVERSATION_PREFIX }
@@ -0,0 +1,55 @@
1
+ import type { IntegrationScope } from '@open-mercato/shared/modules/integrations/types'
2
+ import { discordCredentialsSchema } from './credentials'
3
+ import { DiscordApiError, getDiscordRestClient } from './discord-rest'
4
+
5
+ export type HealthCheckStatus = 'healthy' | 'degraded' | 'unhealthy'
6
+
7
+ export interface HealthCheckResult {
8
+ status: HealthCheckStatus
9
+ message?: string
10
+ details?: Record<string, unknown>
11
+ }
12
+
13
+ /**
14
+ * Liveness probe for the Discord integration. The hub resolves it by the service
15
+ * name declared in `integration.ts` (`channelDiscordHealthCheck`) and passes the
16
+ * tenant-scoped `IntegrationCredentials` blob (the bot token + ids).
17
+ *
18
+ * The bot token is enough for a real probe: we call `GET /users/@me`. A clean
19
+ * response is `healthy`; a `401` (revoked/invalid token) is `unhealthy` with an
20
+ * actionable reason; any other failure is `unhealthy`.
21
+ */
22
+ export const channelDiscordHealthCheck = {
23
+ async check(
24
+ credentials: Record<string, unknown> | null,
25
+ _scope: IntegrationScope,
26
+ ): Promise<HealthCheckResult> {
27
+ const parsed = discordCredentialsSchema.safeParse(credentials ?? {})
28
+ if (!parsed.success) {
29
+ const first = parsed.error.issues[0]
30
+ return {
31
+ status: 'unhealthy',
32
+ message: `Discord credentials invalid: ${first?.message ?? 'unknown validation error'}`,
33
+ details: { reason: 'invalid_credentials' },
34
+ }
35
+ }
36
+ try {
37
+ const user = await getDiscordRestClient().getCurrentUser({ botToken: parsed.data.botToken })
38
+ return {
39
+ status: 'healthy',
40
+ message: 'Discord bot token valid',
41
+ details: { botUserId: user.id, botUsername: user.username },
42
+ }
43
+ } catch (error) {
44
+ const status = error instanceof DiscordApiError ? error.status : 0
45
+ const reason = status === 401 ? 'invalid_token' : 'discord_probe_failed'
46
+ const raw = error instanceof Error ? error.message : 'Discord probe failed'
47
+ const message = raw.replace(/^\[internal\]\s*/, '')
48
+ return {
49
+ status: 'unhealthy',
50
+ message: status === 401 ? 'Discord bot token rejected (401)' : `Discord probe failed: ${message}`,
51
+ details: { reason, status },
52
+ }
53
+ }
54
+ },
55
+ }