@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,237 @@
1
+ 'use client'
2
+
3
+ import * as React from 'react'
4
+ import type { InjectionWidgetComponentProps } from '@open-mercato/shared/modules/widgets/injection'
5
+ import { useT } from '@open-mercato/shared/lib/i18n/context'
6
+ import { flash } from '@open-mercato/ui/backend/FlashMessages'
7
+ import { useGuardedMutation } from '@open-mercato/ui/backend/injection/useGuardedMutation'
8
+ import { apiCall } from '@open-mercato/ui/backend/utils/apiCall'
9
+ import { Button } from '@open-mercato/ui/primitives/button'
10
+ import {
11
+ Dialog,
12
+ DialogContent,
13
+ DialogFooter,
14
+ DialogHeader,
15
+ DialogTitle,
16
+ } from '@open-mercato/ui/primitives/dialog'
17
+ import { Input } from '@open-mercato/ui/primitives/input'
18
+ import { Label } from '@open-mercato/ui/primitives/label'
19
+ import { PasswordInput } from '@open-mercato/ui/primitives/password-input'
20
+
21
+ type WidgetContext = Record<string, unknown> & {
22
+ reload?: () => void
23
+ }
24
+
25
+ type ConnectResponse = {
26
+ channelId?: string
27
+ error?: string
28
+ fieldErrors?: Record<string, string>
29
+ }
30
+
31
+ type FormState = {
32
+ displayName: string
33
+ botToken: string
34
+ applicationId: string
35
+ publicKey: string
36
+ guildId: string
37
+ defaultChannelId: string
38
+ }
39
+
40
+ const INITIAL_FORM: FormState = {
41
+ displayName: '',
42
+ botToken: '',
43
+ applicationId: '',
44
+ publicKey: '',
45
+ guildId: '',
46
+ defaultChannelId: '',
47
+ }
48
+
49
+ export default function ConnectDiscordWidget({
50
+ context,
51
+ }: InjectionWidgetComponentProps<Record<string, unknown>, Record<string, unknown>>) {
52
+ const t = useT()
53
+ const widgetContext = context as WidgetContext | undefined
54
+ const [open, setOpen] = React.useState(false)
55
+ const [pending, setPending] = React.useState(false)
56
+ const [form, setForm] = React.useState<FormState>(INITIAL_FORM)
57
+ const [fieldErrors, setFieldErrors] = React.useState<Record<string, string>>({})
58
+ const { runMutation, retryLastMutation } = useGuardedMutation({
59
+ contextId: 'channel-discord-connect',
60
+ blockedMessage: t('channel_discord.connect.blocked', 'Connection blocked by validation'),
61
+ })
62
+ const mutationContext = React.useMemo(
63
+ () => ({ providerKey: 'discord', retryLastMutation }),
64
+ [retryLastMutation],
65
+ )
66
+
67
+ const update = React.useCallback(
68
+ <K extends keyof FormState>(key: K, value: FormState[K]) => {
69
+ setForm((current) => ({ ...current, [key]: value }))
70
+ setFieldErrors((current) => {
71
+ if (!current[key]) return current
72
+ const next = { ...current }
73
+ delete next[key]
74
+ return next
75
+ })
76
+ },
77
+ [],
78
+ )
79
+
80
+ const submit = React.useCallback(async () => {
81
+ if (pending) return
82
+ setPending(true)
83
+ setFieldErrors({})
84
+ try {
85
+ const response = await runMutation({
86
+ context: mutationContext,
87
+ mutationPayload: { providerKey: 'discord', displayName: form.displayName },
88
+ operation: () =>
89
+ apiCall<ConnectResponse>('/api/communication_channels/channels/connect/credentials', {
90
+ method: 'POST',
91
+ headers: { 'content-type': 'application/json' },
92
+ body: JSON.stringify({
93
+ providerKey: 'discord',
94
+ displayName: form.displayName.trim() || 'Discord',
95
+ credentials: {
96
+ botToken: form.botToken.trim(),
97
+ applicationId: form.applicationId.trim(),
98
+ publicKey: form.publicKey.trim(),
99
+ guildId: form.guildId.trim() || undefined,
100
+ defaultChannelId: form.defaultChannelId.trim() || undefined,
101
+ },
102
+ }),
103
+ }),
104
+ })
105
+ const body = response.result as ConnectResponse | undefined
106
+ if (!response.ok) {
107
+ setFieldErrors(body?.fieldErrors ?? {})
108
+ flash(body?.error ?? t('channel_discord.connect.failed', 'Could not connect Discord bot.'), 'error')
109
+ return
110
+ }
111
+ flash(t('channel_discord.connect.connected', 'Discord channel connected.'), 'success')
112
+ setOpen(false)
113
+ setForm(INITIAL_FORM)
114
+ widgetContext?.reload?.()
115
+ } finally {
116
+ setPending(false)
117
+ }
118
+ }, [form, mutationContext, pending, runMutation, t, widgetContext])
119
+
120
+ const onDialogKeyDown = React.useCallback(
121
+ (event: React.KeyboardEvent<HTMLDivElement>) => {
122
+ if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {
123
+ event.preventDefault()
124
+ void submit()
125
+ }
126
+ },
127
+ [submit],
128
+ )
129
+
130
+ return (
131
+ <>
132
+ <Button type="button" variant="outline" onClick={() => setOpen(true)}>
133
+ {t('channel_discord.connect.button', 'Connect Discord')}
134
+ </Button>
135
+ <Dialog open={open} onOpenChange={setOpen}>
136
+ <DialogContent onKeyDown={onDialogKeyDown}>
137
+ <DialogHeader>
138
+ <DialogTitle>{t('channel_discord.connect.title', 'Connect Discord bot')}</DialogTitle>
139
+ </DialogHeader>
140
+
141
+ <div className="grid gap-4 py-2">
142
+ <Field
143
+ label={t('channel_discord.connect.fields.displayName', 'Display name')}
144
+ error={fieldErrors.displayName}
145
+ >
146
+ <Input
147
+ value={form.displayName}
148
+ onChange={(event) => update('displayName', event.target.value)}
149
+ aria-invalid={Boolean(fieldErrors.displayName)}
150
+ />
151
+ </Field>
152
+
153
+ <Field
154
+ label={t('channel_discord.connect.fields.botToken', 'Bot token')}
155
+ error={fieldErrors.botToken}
156
+ >
157
+ <PasswordInput
158
+ value={form.botToken}
159
+ onChange={(event) => update('botToken', event.target.value)}
160
+ aria-invalid={Boolean(fieldErrors.botToken)}
161
+ />
162
+ </Field>
163
+
164
+ <div className="grid gap-3 md:grid-cols-2">
165
+ <Field
166
+ label={t('channel_discord.connect.fields.applicationId', 'Application ID')}
167
+ error={fieldErrors.applicationId}
168
+ >
169
+ <Input
170
+ value={form.applicationId}
171
+ onChange={(event) => update('applicationId', event.target.value)}
172
+ aria-invalid={Boolean(fieldErrors.applicationId)}
173
+ />
174
+ </Field>
175
+ <Field
176
+ label={t('channel_discord.connect.fields.publicKey', 'Public key')}
177
+ error={fieldErrors.publicKey}
178
+ >
179
+ <Input
180
+ value={form.publicKey}
181
+ onChange={(event) => update('publicKey', event.target.value)}
182
+ aria-invalid={Boolean(fieldErrors.publicKey)}
183
+ />
184
+ </Field>
185
+ </div>
186
+
187
+ <div className="grid gap-3 md:grid-cols-2">
188
+ <Field
189
+ label={t('channel_discord.connect.fields.guildId', 'Guild (server) ID')}
190
+ error={fieldErrors.guildId}
191
+ >
192
+ <Input
193
+ value={form.guildId}
194
+ onChange={(event) => update('guildId', event.target.value)}
195
+ aria-invalid={Boolean(fieldErrors.guildId)}
196
+ />
197
+ </Field>
198
+ <Field
199
+ label={t('channel_discord.connect.fields.defaultChannelId', 'Default channel ID')}
200
+ error={fieldErrors.defaultChannelId}
201
+ >
202
+ <Input
203
+ value={form.defaultChannelId}
204
+ onChange={(event) => update('defaultChannelId', event.target.value)}
205
+ aria-invalid={Boolean(fieldErrors.defaultChannelId)}
206
+ />
207
+ </Field>
208
+ </div>
209
+ </div>
210
+
211
+ <DialogFooter>
212
+ <Button type="button" variant="outline" onClick={() => setOpen(false)} disabled={pending}>
213
+ {t('channel_discord.connect.cancel', 'Cancel')}
214
+ </Button>
215
+ <Button type="button" onClick={() => void submit()} disabled={pending}>
216
+ {pending
217
+ ? t('channel_discord.connect.connecting', 'Connecting...')
218
+ : t('channel_discord.connect.save', 'Connect')}
219
+ </Button>
220
+ </DialogFooter>
221
+ </DialogContent>
222
+ </Dialog>
223
+ </>
224
+ )
225
+ }
226
+
227
+ function Field(props: { label: string; error?: string; children: React.ReactNode }) {
228
+ return (
229
+ <label className="grid gap-1.5">
230
+ <Label asChild>
231
+ <span>{props.label}</span>
232
+ </Label>
233
+ {props.children}
234
+ {props.error ? <span className="text-xs text-destructive">{props.error}</span> : null}
235
+ </label>
236
+ )
237
+ }
@@ -0,0 +1,16 @@
1
+ import type { InjectionWidgetModule } from '@open-mercato/shared/modules/widgets/injection'
2
+ import ConnectDiscordWidget from './widget.client'
3
+
4
+ const widget: InjectionWidgetModule<Record<string, unknown>, Record<string, unknown>> = {
5
+ metadata: {
6
+ id: 'channel_discord.injection.connect',
7
+ title: 'Connect Discord',
8
+ description: 'Connects a Discord bot as a two-way communication channel.',
9
+ features: ['communication_channels.connect_user_channel'],
10
+ priority: 100,
11
+ enabled: true,
12
+ },
13
+ Widget: ConnectDiscordWidget,
14
+ }
15
+
16
+ export default widget
@@ -0,0 +1,24 @@
1
+ import type { ModuleInjectionTable } from '@open-mercato/shared/modules/widgets/injection'
2
+ import { channelDiscordDetailWidgetSpotId } from '../integration'
3
+
4
+ export const injectionTable: ModuleInjectionTable = {
5
+ 'profile:communication-channels:connect': [
6
+ {
7
+ widgetId: 'channel_discord.injection.connect',
8
+ priority: 120,
9
+ },
10
+ ],
11
+ // The Discord integration's own detail page — the only spot in the product
12
+ // that is Discord-scoped by construction, which is what a per-channel AI
13
+ // setting needs (see the widget's file header).
14
+ [channelDiscordDetailWidgetSpotId]: [
15
+ {
16
+ widgetId: 'channel_discord.injection.ai-auto-reply',
17
+ priority: 40,
18
+ kind: 'group',
19
+ groupLabel: 'channel_discord.aiAutoReply.widget.title',
20
+ },
21
+ ],
22
+ }
23
+
24
+ export default injectionTable
@@ -0,0 +1,88 @@
1
+ import {
2
+ botTokenFingerprint,
3
+ findChannelWithSameBot,
4
+ type GatewayConnectionEntry,
5
+ } from '../discord-gateway'
6
+ import type { DiscordGatewayHandle } from '../../lib/discord-gateway-client'
7
+
8
+ /**
9
+ * Regression guard for the duplicate-session half of #4977.
10
+ *
11
+ * The hub derives a channel's `externalIdentifier` by sniffing the credential
12
+ * bag for email-shaped keys (`username` / `email` / `fromAddress`), none of
13
+ * which a Discord credential bag has. Every reconnect therefore inserts a second
14
+ * active channel row for the same bot instead of healing the first, and QA
15
+ * observed the gateway opening two parallel WebSocket sessions with two distinct
16
+ * Discord session ids — defeating the single-identify-per-bot discipline that
17
+ * `concurrency: 1` exists to enforce.
18
+ *
19
+ * The real fix is hub-side (let the adapter supply an identity); this guard is
20
+ * the interim one that keeps the second socket from ever opening.
21
+ */
22
+ function fakeEntry(
23
+ tenantId: string,
24
+ fingerprint: string | undefined,
25
+ active = true,
26
+ ): GatewayConnectionEntry {
27
+ const handle: DiscordGatewayHandle = { close: jest.fn(), isActive: () => active }
28
+ return { handle, tenantId, organizationId: null, botTokenFingerprint: fingerprint }
29
+ }
30
+
31
+ describe('botTokenFingerprint', () => {
32
+ it('is stable for the same token and different for another', () => {
33
+ expect(botTokenFingerprint('bot-token-a')).toBe(botTokenFingerprint('bot-token-a'))
34
+ expect(botTokenFingerprint('bot-token-a')).not.toBe(botTokenFingerprint('bot-token-b'))
35
+ })
36
+
37
+ it('never reveals the token it was derived from', () => {
38
+ const token = 'MTIzNDU2Nzg5.super.secret-bot-token'
39
+ const fingerprint = botTokenFingerprint(token)
40
+ expect(fingerprint).not.toContain(token)
41
+ expect(fingerprint).toMatch(/^[0-9a-f]{64}$/)
42
+ })
43
+ })
44
+
45
+ describe('findChannelWithSameBot', () => {
46
+ it('reports the channel already serving the same bot', () => {
47
+ const fingerprint = botTokenFingerprint('one-bot')
48
+ const connections = new Map<string, GatewayConnectionEntry>([
49
+ ['chan-first', fakeEntry('t1', fingerprint)],
50
+ ])
51
+
52
+ expect(findChannelWithSameBot('chan-duplicate', fingerprint, connections)).toBe('chan-first')
53
+ })
54
+
55
+ it('does not treat a channel as its own duplicate', () => {
56
+ const fingerprint = botTokenFingerprint('one-bot')
57
+ const connections = new Map<string, GatewayConnectionEntry>([
58
+ ['chan-first', fakeEntry('t1', fingerprint)],
59
+ ])
60
+
61
+ expect(findChannelWithSameBot('chan-first', fingerprint, connections)).toBeNull()
62
+ })
63
+
64
+ it('allows a different bot to connect alongside', () => {
65
+ const connections = new Map<string, GatewayConnectionEntry>([
66
+ ['chan-first', fakeEntry('t1', botTokenFingerprint('bot-a'))],
67
+ ])
68
+
69
+ expect(findChannelWithSameBot('chan-other', botTokenFingerprint('bot-b'), connections)).toBeNull()
70
+ })
71
+
72
+ it('ignores a dead session so a genuinely lost connection can be replaced', () => {
73
+ const fingerprint = botTokenFingerprint('one-bot')
74
+ const connections = new Map<string, GatewayConnectionEntry>([
75
+ ['chan-first', fakeEntry('t1', fingerprint, false)],
76
+ ])
77
+
78
+ expect(findChannelWithSameBot('chan-second', fingerprint, connections)).toBeNull()
79
+ })
80
+
81
+ it('ignores entries recorded before fingerprints existed', () => {
82
+ const connections = new Map<string, GatewayConnectionEntry>([
83
+ ['chan-legacy', fakeEntry('t1', undefined)],
84
+ ])
85
+
86
+ expect(findChannelWithSameBot('chan-new', botTokenFingerprint('bot-a'), connections)).toBeNull()
87
+ })
88
+ })
@@ -0,0 +1,179 @@
1
+ import {
2
+ buildGatewayChannelFilter,
3
+ isConnectionLive,
4
+ reconcileGatewayConnections,
5
+ type GatewayConnectionEntry,
6
+ } from '../discord-gateway'
7
+ import type { DiscordGatewayHandle } from '../../lib/discord-gateway-client'
8
+
9
+ function fakeEntry(
10
+ tenantId: string,
11
+ active = true,
12
+ organizationId: string | null = null,
13
+ ): { entry: GatewayConnectionEntry; close: jest.Mock } {
14
+ const close = jest.fn()
15
+ const handle: DiscordGatewayHandle = { close, isActive: () => active }
16
+ return { entry: { handle, tenantId, organizationId }, close }
17
+ }
18
+
19
+ /**
20
+ * `GatewayJobPayload.organizationId` used to be accepted and silently discarded:
21
+ * the query filtered on tenant only, so a job scoped to one organization
22
+ * connected every organization in the tenant. A scope parameter that is declared
23
+ * and then thrown away is how multi-tenant defects start, so the filter is
24
+ * pinned here.
25
+ */
26
+ describe('buildGatewayChannelFilter (scoped job payloads)', () => {
27
+ it('connects every active discord channel when the payload carries no scope', () => {
28
+ expect(buildGatewayChannelFilter({})).toEqual({
29
+ providerKey: 'discord',
30
+ isActive: true,
31
+ deletedAt: null,
32
+ })
33
+ })
34
+
35
+ it('narrows by tenant when the payload names one', () => {
36
+ expect(buildGatewayChannelFilter({ tenantId: 't1' })).toEqual({
37
+ providerKey: 'discord',
38
+ isActive: true,
39
+ deletedAt: null,
40
+ tenantId: 't1',
41
+ })
42
+ })
43
+
44
+ it('narrows by organization when the payload names one', () => {
45
+ expect(buildGatewayChannelFilter({ tenantId: 't1', organizationId: 'org-a' })).toEqual({
46
+ providerKey: 'discord',
47
+ isActive: true,
48
+ deletedAt: null,
49
+ tenantId: 't1',
50
+ organizationId: 'org-a',
51
+ })
52
+ })
53
+
54
+ it('treats an explicitly null organization as "no organization filter", not "tenant-wide channels only"', () => {
55
+ // The payload type allows `organizationId: null`; that is the absence of a
56
+ // filter, not a request for rows whose organization_id IS NULL.
57
+ expect(buildGatewayChannelFilter({ tenantId: 't1', organizationId: null })).toEqual({
58
+ providerKey: 'discord',
59
+ isActive: true,
60
+ deletedAt: null,
61
+ tenantId: 't1',
62
+ })
63
+ })
64
+ })
65
+
66
+ describe('reconcileGatewayConnections', () => {
67
+ it('closes + removes a connection whose channel dropped out of the active set', () => {
68
+ const a = fakeEntry('t1')
69
+ const b = fakeEntry('t1')
70
+ const connections = new Map<string, GatewayConnectionEntry>([
71
+ ['chan-a', a.entry],
72
+ ['chan-b', b.entry],
73
+ ])
74
+
75
+ // chan-b is no longer active (deactivated / soft-deleted).
76
+ const removed = reconcileGatewayConnections(new Set(['chan-a']), connections)
77
+
78
+ expect(removed).toEqual(['chan-b'])
79
+ expect(b.close).toHaveBeenCalledTimes(1)
80
+ expect(a.close).not.toHaveBeenCalled()
81
+ expect(connections.has('chan-b')).toBe(false)
82
+ expect(connections.has('chan-a')).toBe(true)
83
+ })
84
+
85
+ it('keeps every connection when all channels are still active', () => {
86
+ const a = fakeEntry('t1')
87
+ const connections = new Map<string, GatewayConnectionEntry>([['chan-a', a.entry]])
88
+ const removed = reconcileGatewayConnections(new Set(['chan-a']), connections)
89
+ expect(removed).toEqual([])
90
+ expect(a.close).not.toHaveBeenCalled()
91
+ expect(connections.has('chan-a')).toBe(true)
92
+ })
93
+
94
+ it('a tenant-scoped refresh never tears down another tenant’s sockets', () => {
95
+ const t1 = fakeEntry('t1')
96
+ const t2 = fakeEntry('t2')
97
+ const connections = new Map<string, GatewayConnectionEntry>([
98
+ ['chan-t1', t1.entry],
99
+ ['chan-t2', t2.entry],
100
+ ])
101
+
102
+ // Scoped refresh for t1 returns no active t1 channels, but must NOT touch t2.
103
+ const removed = reconcileGatewayConnections(new Set<string>(), connections, { tenantId: 't1' })
104
+
105
+ expect(removed).toEqual(['chan-t1'])
106
+ expect(t1.close).toHaveBeenCalledTimes(1)
107
+ expect(t2.close).not.toHaveBeenCalled()
108
+ expect(connections.has('chan-t2')).toBe(true)
109
+ })
110
+
111
+ it('an organization-scoped refresh never tears down a sibling organization’s sockets', () => {
112
+ // A job payload carrying `organizationId` used to be accepted and discarded:
113
+ // the query and the reconciliation both filtered on tenant only, so a refresh
114
+ // scoped to one organization would close every OTHER organization's sockets
115
+ // in the same tenant, because none of their channels appeared in the
116
+ // (tenant-wide) active set it compared against.
117
+ const orgA = fakeEntry('t1', true, 'org-a')
118
+ const orgB = fakeEntry('t1', true, 'org-b')
119
+ const tenantWide = fakeEntry('t1', true, null)
120
+ const connections = new Map<string, GatewayConnectionEntry>([
121
+ ['chan-org-a', orgA.entry],
122
+ ['chan-org-b', orgB.entry],
123
+ ['chan-tenant-wide', tenantWide.entry],
124
+ ])
125
+
126
+ const removed = reconcileGatewayConnections(new Set<string>(), connections, {
127
+ tenantId: 't1',
128
+ organizationId: 'org-a',
129
+ })
130
+
131
+ expect(removed).toEqual(['chan-org-a'])
132
+ expect(orgA.close).toHaveBeenCalledTimes(1)
133
+ expect(orgB.close).not.toHaveBeenCalled()
134
+ expect(tenantWide.close).not.toHaveBeenCalled()
135
+ expect(connections.has('chan-org-b')).toBe(true)
136
+ expect(connections.has('chan-tenant-wide')).toBe(true)
137
+ })
138
+
139
+ it('an unscoped refresh still reconciles every tenant and organization', () => {
140
+ const orgA = fakeEntry('t1', true, 'org-a')
141
+ const otherTenant = fakeEntry('t2', true, 'org-z')
142
+ const connections = new Map<string, GatewayConnectionEntry>([
143
+ ['chan-org-a', orgA.entry],
144
+ ['chan-t2', otherTenant.entry],
145
+ ])
146
+
147
+ const removed = reconcileGatewayConnections(new Set<string>(), connections)
148
+
149
+ expect(removed.sort()).toEqual(['chan-org-a', 'chan-t2'])
150
+ expect(connections.size).toBe(0)
151
+ })
152
+ })
153
+
154
+ describe('isConnectionLive (refresh must not churn healthy sockets)', () => {
155
+ it('reports a running session as live so the refresh job leaves it alone', () => {
156
+ const live = fakeEntry('t1', true)
157
+ expect(isConnectionLive(live.entry)).toBe(true)
158
+ })
159
+
160
+ it('reports a stopped session as dead so the refresh job replaces it', () => {
161
+ const dead = fakeEntry('t1', false)
162
+ expect(isConnectionLive(dead.entry)).toBe(false)
163
+ })
164
+
165
+ it('treats a missing entry and a throwing handle as dead', () => {
166
+ expect(isConnectionLive(undefined)).toBe(false)
167
+ const throwing: GatewayConnectionEntry = {
168
+ tenantId: 't1',
169
+ organizationId: null,
170
+ handle: {
171
+ close: jest.fn(),
172
+ isActive: () => {
173
+ throw new Error('socket gone')
174
+ },
175
+ },
176
+ }
177
+ expect(isConnectionLive(throwing)).toBe(false)
178
+ })
179
+ })