@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,196 @@
1
+ "use client";
2
+ import { jsx, jsxs } from "react/jsx-runtime";
3
+ import * as React from "react";
4
+ import { useParams } from "next/navigation";
5
+ import { useT } from "@open-mercato/shared/lib/i18n/context";
6
+ import { Page, PageBody } from "@open-mercato/ui/backend/Page";
7
+ import { CrudForm } from "@open-mercato/ui/backend/CrudForm";
8
+ import { ErrorMessage, LoadingMessage, RecordNotFoundState } from "@open-mercato/ui/backend/detail";
9
+ import { flash } from "@open-mercato/ui/backend/FlashMessages";
10
+ import { apiCall } from "@open-mercato/ui/backend/utils/apiCall";
11
+ import { updateCrud } from "@open-mercato/ui/backend/utils/crud";
12
+ import { createCrudFormError } from "@open-mercato/ui/backend/utils/serverErrors";
13
+ const CHANNELS_LIST_HREF = "/backend/communication_channels/channels";
14
+ function DiscordAiAutoReplyPage() {
15
+ const t = useT();
16
+ const params = useParams();
17
+ const channelId = params?.id ?? "";
18
+ const [settings, setSettings] = React.useState(null);
19
+ const [isLoading, setIsLoading] = React.useState(true);
20
+ const [errorMessage, setErrorMessage] = React.useState(null);
21
+ const [notFound, setNotFound] = React.useState(false);
22
+ const load = React.useCallback(async () => {
23
+ if (!channelId) return;
24
+ setIsLoading(true);
25
+ setErrorMessage(null);
26
+ setNotFound(false);
27
+ const response = await apiCall(
28
+ `/api/channel_discord/channels/${encodeURIComponent(channelId)}/ai-auto-reply`
29
+ ).catch(() => null);
30
+ if (!response || !response.ok) {
31
+ const status = response?.status;
32
+ if (status === 404) {
33
+ setNotFound(true);
34
+ } else {
35
+ const body = response?.result;
36
+ setErrorMessage(
37
+ body?.error ?? t("channel_discord.aiAutoReply.errors.load", "Failed to load AI auto-reply settings")
38
+ );
39
+ }
40
+ setSettings(null);
41
+ } else {
42
+ setSettings(response.result ?? null);
43
+ }
44
+ setIsLoading(false);
45
+ }, [channelId, t]);
46
+ React.useEffect(() => {
47
+ void load();
48
+ }, [load]);
49
+ const fields = React.useMemo(() => {
50
+ const agentOptions = (settings?.agents ?? []).map((agent) => ({
51
+ value: agent.id,
52
+ label: agent.invocable ? agent.label : t(
53
+ "channel_discord.aiAutoReply.fields.agentMissingFeatures",
54
+ "{label} \u2014 needs {features}",
55
+ { label: agent.label, features: agent.missingFeatures.join(", ") }
56
+ )
57
+ }));
58
+ return [
59
+ {
60
+ id: "aiAutoReplyEnabled",
61
+ type: "checkbox",
62
+ label: t("channel_discord.aiAutoReply.fields.enabled", "Answer inbound Discord messages with AI"),
63
+ description: t(
64
+ "channel_discord.aiAutoReply.fields.enabledHelp",
65
+ "Off by default. When on, straightforward messages are answered automatically; anything sensitive, low-confidence or suspicious is proposed for a human to approve instead."
66
+ ),
67
+ disabled: !settings?.aiAvailable
68
+ },
69
+ {
70
+ id: "aiAgentId",
71
+ type: "select",
72
+ label: t("channel_discord.aiAutoReply.fields.agent", "Agent"),
73
+ description: t(
74
+ "channel_discord.aiAutoReply.fields.agentHelp",
75
+ "Only agents that produce structured output can answer a channel. Grant the tenant channel-bot user the agent\u2019s required features before choosing an agent from another module."
76
+ ),
77
+ options: agentOptions,
78
+ required: true,
79
+ visibleWhen: { field: "aiAutoReplyEnabled", equals: true }
80
+ }
81
+ ];
82
+ }, [settings, t]);
83
+ const initialValues = React.useMemo(() => {
84
+ if (!settings) return {};
85
+ const invocable = settings.agents.filter((agent) => agent.invocable);
86
+ const fallbackAgentId = invocable.some((agent) => agent.id === settings.defaultAgentId) ? settings.defaultAgentId : invocable[0]?.id ?? settings.agents[0]?.id ?? "";
87
+ return {
88
+ id: settings.channelId,
89
+ updatedAt: settings.updatedAt,
90
+ aiAutoReplyEnabled: settings.aiAutoReplyEnabled,
91
+ aiAgentId: settings.aiAgentId ?? fallbackAgentId
92
+ };
93
+ }, [settings]);
94
+ const onSubmit = React.useCallback(
95
+ async (values) => {
96
+ const enabled = Boolean(values.aiAutoReplyEnabled);
97
+ const agentId = (values.aiAgentId ?? "").trim();
98
+ if (enabled && !agentId) {
99
+ throw createCrudFormError(
100
+ t("channel_discord.aiAutoReply.errors.agentRequired", "Choose the agent that should answer this channel"),
101
+ { aiAgentId: t("channel_discord.aiAutoReply.errors.agentRequired", "Choose the agent that should answer this channel") }
102
+ );
103
+ }
104
+ const chosen = settings?.agents.find((agent) => agent.id === agentId);
105
+ if (enabled && chosen && !chosen.invocable) {
106
+ const message = t(
107
+ "channel_discord.aiAutoReply.errors.agentFeaturesMissing",
108
+ "The channel-bot user is missing the features this agent needs: {features}. Grant them first, or pick another agent.",
109
+ { features: chosen.missingFeatures.join(", ") }
110
+ );
111
+ throw createCrudFormError(message, { aiAgentId: message });
112
+ }
113
+ await updateCrud(
114
+ `channel_discord/channels/${encodeURIComponent(channelId)}/ai-auto-reply`,
115
+ {
116
+ aiAutoReplyEnabled: enabled,
117
+ aiAgentId: enabled ? agentId : void 0
118
+ },
119
+ {
120
+ errorMessage: t(
121
+ "channel_discord.aiAutoReply.errors.save",
122
+ "Failed to save AI auto-reply settings"
123
+ )
124
+ }
125
+ );
126
+ flash(t("channel_discord.aiAutoReply.saved", "AI auto-reply settings saved."), "success");
127
+ await load();
128
+ },
129
+ [channelId, load, settings, t]
130
+ );
131
+ if (isLoading) {
132
+ return /* @__PURE__ */ jsx(Page, { children: /* @__PURE__ */ jsx(PageBody, { children: /* @__PURE__ */ jsx(LoadingMessage, { label: t("channel_discord.aiAutoReply.loading", "Loading AI auto-reply settings...") }) }) });
133
+ }
134
+ if (notFound) {
135
+ return /* @__PURE__ */ jsx(Page, { children: /* @__PURE__ */ jsx(PageBody, { children: /* @__PURE__ */ jsx(
136
+ RecordNotFoundState,
137
+ {
138
+ label: t("channel_discord.aiAutoReply.notFound", "Discord channel not found"),
139
+ backHref: CHANNELS_LIST_HREF
140
+ }
141
+ ) }) });
142
+ }
143
+ if (errorMessage || !settings) {
144
+ return /* @__PURE__ */ jsx(Page, { children: /* @__PURE__ */ jsx(PageBody, { children: /* @__PURE__ */ jsx(
145
+ ErrorMessage,
146
+ {
147
+ label: errorMessage ?? t("channel_discord.aiAutoReply.errors.load", "Failed to load AI auto-reply settings")
148
+ }
149
+ ) }) });
150
+ }
151
+ return /* @__PURE__ */ jsx(Page, { children: /* @__PURE__ */ jsx(PageBody, { children: /* @__PURE__ */ jsx(
152
+ CrudForm,
153
+ {
154
+ title: t("channel_discord.aiAutoReply.page.title", "Discord AI auto-reply"),
155
+ backHref: CHANNELS_LIST_HREF,
156
+ cancelHref: CHANNELS_LIST_HREF,
157
+ fields,
158
+ initialValues,
159
+ onSubmit,
160
+ submitLabel: t("channel_discord.aiAutoReply.save", "Save"),
161
+ contentHeader: /* @__PURE__ */ jsxs("div", { className: "mb-4 space-y-2 text-sm text-muted-foreground", children: [
162
+ /* @__PURE__ */ jsxs("p", { children: [
163
+ t(
164
+ "channel_discord.aiAutoReply.intro",
165
+ "Choose whether this Discord channel may answer on its own, and which agent drafts the reply."
166
+ ),
167
+ " ",
168
+ /* @__PURE__ */ jsx("span", { className: "font-medium text-foreground", children: settings.displayName })
169
+ ] }),
170
+ settings.aiAvailable ? null : /* @__PURE__ */ jsx("p", { children: t(
171
+ "channel_discord.aiAutoReply.unavailable",
172
+ "The AI assistant module is not installed in this deployment, so auto-reply cannot be enabled. The channel keeps working as a normal inbox."
173
+ ) }),
174
+ settings.aiAutoReplyLastError ? (
175
+ // An armed channel whose runtime call keeps being refused is the
176
+ // dormancy failure wearing a green tag. It is reported here, on the
177
+ // surface that armed it, rather than only in a subscriber log line.
178
+ /* @__PURE__ */ jsxs("p", { className: "rounded-md border border-status-error-border bg-status-error-bg p-3 text-status-error-text", children: [
179
+ t(
180
+ "channel_discord.aiAutoReply.lastError",
181
+ "The last auto-reply attempt on this channel produced nothing: {reason}",
182
+ { reason: settings.aiAutoReplyLastError }
183
+ ),
184
+ settings.aiAutoReplyLastErrorAt ? /* @__PURE__ */ jsx("span", { className: "ml-1", children: t("channel_discord.aiAutoReply.lastErrorAt", "(first seen {at})", {
185
+ at: new Date(settings.aiAutoReplyLastErrorAt).toLocaleString()
186
+ }) }) : null
187
+ ] })
188
+ ) : null
189
+ ] })
190
+ }
191
+ ) }) });
192
+ }
193
+ export {
194
+ DiscordAiAutoReplyPage as default
195
+ };
196
+ //# sourceMappingURL=page.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../../../../../../src/modules/channel_discord/backend/channel_discord/channels/%5Bid%5D/ai-auto-reply/page.tsx"],
4
+ "sourcesContent": ["'use client'\n\nimport * as React from 'react'\nimport { useParams } from 'next/navigation'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\nimport { Page, PageBody } from '@open-mercato/ui/backend/Page'\nimport { CrudForm, type CrudField } from '@open-mercato/ui/backend/CrudForm'\nimport { ErrorMessage, LoadingMessage, RecordNotFoundState } from '@open-mercato/ui/backend/detail'\nimport { flash } from '@open-mercato/ui/backend/FlashMessages'\nimport { apiCall } from '@open-mercato/ui/backend/utils/apiCall'\nimport { updateCrud } from '@open-mercato/ui/backend/utils/crud'\nimport { createCrudFormError } from '@open-mercato/ui/backend/utils/serverErrors'\n\ntype EligibleAgent = {\n id: string\n label: string\n description: string\n requiredFeatures: string[]\n /** Whether the auto-reply principal holds the agent's `requiredFeatures`. */\n invocable: boolean\n missingFeatures: string[]\n}\n\ntype AiAutoReplySettings = {\n channelId: string\n displayName: string\n updatedAt: string | null\n aiAutoReplyEnabled: boolean\n aiAgentId: string | null\n aiAutoReplyLastError: string | null\n aiAutoReplyLastErrorAt: string | null\n defaultAgentId: string\n aiAvailable: boolean\n agents: EligibleAgent[]\n}\n\ntype FormValues = {\n id: string\n updatedAt: string | null\n aiAutoReplyEnabled: boolean\n aiAgentId: string\n}\n\nconst CHANNELS_LIST_HREF = '/backend/communication_channels/channels'\n\n/**\n * Per-channel AI auto-reply settings (issue #4778) \u2014 the surface that writes\n * `aiAutoReplyEnabled` / `aiAgentId`, the two keys the subscriber reads.\n *\n * It is a `CrudForm` rather than a hand-rolled form so the save inherits the\n * platform's contract for free: zod validation with field-level errors, and the\n * optimistic-lock header auto-derived from `initialValues.updatedAt`, so a save\n * from a tab opened before someone else touched the channel surfaces the shared\n * conflict bar instead of overwriting them.\n */\nexport default function DiscordAiAutoReplyPage() {\n const t = useT()\n const params = useParams<{ id: string }>()\n const channelId = (params?.id as string) ?? ''\n\n const [settings, setSettings] = React.useState<AiAutoReplySettings | null>(null)\n const [isLoading, setIsLoading] = React.useState(true)\n const [errorMessage, setErrorMessage] = React.useState<string | null>(null)\n const [notFound, setNotFound] = React.useState(false)\n\n const load = React.useCallback(async () => {\n if (!channelId) return\n setIsLoading(true)\n setErrorMessage(null)\n setNotFound(false)\n const response = await apiCall<AiAutoReplySettings>(\n `/api/channel_discord/channels/${encodeURIComponent(channelId)}/ai-auto-reply`,\n ).catch(() => null)\n if (!response || !response.ok) {\n const status = (response as { status?: number } | null)?.status\n if (status === 404) {\n setNotFound(true)\n } else {\n const body = response?.result as { error?: string } | undefined\n setErrorMessage(\n body?.error ?? t('channel_discord.aiAutoReply.errors.load', 'Failed to load AI auto-reply settings'),\n )\n }\n setSettings(null)\n } else {\n setSettings(response.result ?? null)\n }\n setIsLoading(false)\n }, [channelId, t])\n\n React.useEffect(() => {\n void load()\n }, [load])\n\n const fields = React.useMemo<CrudField[]>(() => {\n // An agent the auto-reply principal cannot invoke stays in the list \u2014 hiding\n // it would leave the operator wondering where an agent they can see elsewhere\n // went \u2014 but it says what it needs, so the requirement is visible before the\n // save rather than only in the 400 that follows.\n const agentOptions = (settings?.agents ?? []).map((agent) => ({\n value: agent.id,\n label: agent.invocable\n ? agent.label\n : t(\n 'channel_discord.aiAutoReply.fields.agentMissingFeatures',\n '{label} \u2014 needs {features}',\n { label: agent.label, features: agent.missingFeatures.join(', ') },\n ),\n }))\n return [\n {\n id: 'aiAutoReplyEnabled',\n type: 'checkbox',\n label: t('channel_discord.aiAutoReply.fields.enabled', 'Answer inbound Discord messages with AI'),\n description: t(\n 'channel_discord.aiAutoReply.fields.enabledHelp',\n 'Off by default. When on, straightforward messages are answered automatically; anything sensitive, low-confidence or suspicious is proposed for a human to approve instead.',\n ),\n disabled: !settings?.aiAvailable,\n },\n {\n id: 'aiAgentId',\n type: 'select',\n label: t('channel_discord.aiAutoReply.fields.agent', 'Agent'),\n description: t(\n 'channel_discord.aiAutoReply.fields.agentHelp',\n 'Only agents that produce structured output can answer a channel. Grant the tenant channel-bot user the agent\u2019s required features before choosing an agent from another module.',\n ),\n options: agentOptions,\n required: true,\n visibleWhen: { field: 'aiAutoReplyEnabled', equals: true },\n },\n ]\n }, [settings, t])\n\n const initialValues = React.useMemo<Partial<FormValues>>(() => {\n if (!settings) return {}\n // Default to an agent the principal can actually invoke, so the form does not\n // pre-select a choice the save will reject.\n const invocable = settings.agents.filter((agent) => agent.invocable)\n const fallbackAgentId = invocable.some((agent) => agent.id === settings.defaultAgentId)\n ? settings.defaultAgentId\n : (invocable[0]?.id ?? settings.agents[0]?.id ?? '')\n return {\n id: settings.channelId,\n updatedAt: settings.updatedAt,\n aiAutoReplyEnabled: settings.aiAutoReplyEnabled,\n aiAgentId: settings.aiAgentId ?? fallbackAgentId,\n }\n }, [settings])\n\n const onSubmit = React.useCallback(\n async (values: FormValues) => {\n const enabled = Boolean(values.aiAutoReplyEnabled)\n const agentId = (values.aiAgentId ?? '').trim()\n if (enabled && !agentId) {\n throw createCrudFormError(\n t('channel_discord.aiAutoReply.errors.agentRequired', 'Choose the agent that should answer this channel'),\n { aiAgentId: t('channel_discord.aiAutoReply.errors.agentRequired', 'Choose the agent that should answer this channel') },\n )\n }\n // The route enforces this too \u2014 it has to, since a role can change between\n // the load and the save. Checking here as well turns the common case into an\n // inline field error instead of a round trip.\n const chosen = settings?.agents.find((agent) => agent.id === agentId)\n if (enabled && chosen && !chosen.invocable) {\n const message = t(\n 'channel_discord.aiAutoReply.errors.agentFeaturesMissing',\n 'The channel-bot user is missing the features this agent needs: {features}. Grant them first, or pick another agent.',\n { features: chosen.missingFeatures.join(', ') },\n )\n throw createCrudFormError(message, { aiAgentId: message })\n }\n // Deliberately unguarded: a 409 from the optimistic-lock check has to reach\n // CrudForm, which renders it on the shared conflict bar. Catching it here\n // would surface the same bar and then let the form believe the save\n // succeeded.\n await updateCrud(\n `channel_discord/channels/${encodeURIComponent(channelId)}/ai-auto-reply`,\n {\n aiAutoReplyEnabled: enabled,\n aiAgentId: enabled ? agentId : undefined,\n },\n {\n errorMessage: t(\n 'channel_discord.aiAutoReply.errors.save',\n 'Failed to save AI auto-reply settings',\n ),\n },\n )\n flash(t('channel_discord.aiAutoReply.saved', 'AI auto-reply settings saved.'), 'success')\n await load()\n },\n [channelId, load, settings, t],\n )\n\n if (isLoading) {\n return (\n <Page>\n <PageBody>\n <LoadingMessage label={t('channel_discord.aiAutoReply.loading', 'Loading AI auto-reply settings...')} />\n </PageBody>\n </Page>\n )\n }\n\n if (notFound) {\n return (\n <Page>\n <PageBody>\n <RecordNotFoundState\n label={t('channel_discord.aiAutoReply.notFound', 'Discord channel not found')}\n backHref={CHANNELS_LIST_HREF}\n />\n </PageBody>\n </Page>\n )\n }\n\n if (errorMessage || !settings) {\n return (\n <Page>\n <PageBody>\n <ErrorMessage\n label={\n errorMessage\n ?? t('channel_discord.aiAutoReply.errors.load', 'Failed to load AI auto-reply settings')\n }\n />\n </PageBody>\n </Page>\n )\n }\n\n return (\n <Page>\n <PageBody>\n <CrudForm<FormValues>\n title={t('channel_discord.aiAutoReply.page.title', 'Discord AI auto-reply')}\n backHref={CHANNELS_LIST_HREF}\n cancelHref={CHANNELS_LIST_HREF}\n fields={fields}\n initialValues={initialValues}\n onSubmit={onSubmit}\n submitLabel={t('channel_discord.aiAutoReply.save', 'Save')}\n contentHeader={\n <div className=\"mb-4 space-y-2 text-sm text-muted-foreground\">\n <p>\n {t(\n 'channel_discord.aiAutoReply.intro',\n 'Choose whether this Discord channel may answer on its own, and which agent drafts the reply.',\n )}{' '}\n <span className=\"font-medium text-foreground\">{settings.displayName}</span>\n </p>\n {settings.aiAvailable ? null : (\n <p>\n {t(\n 'channel_discord.aiAutoReply.unavailable',\n 'The AI assistant module is not installed in this deployment, so auto-reply cannot be enabled. The channel keeps working as a normal inbox.',\n )}\n </p>\n )}\n {settings.aiAutoReplyLastError ? (\n // An armed channel whose runtime call keeps being refused is the\n // dormancy failure wearing a green tag. It is reported here, on the\n // surface that armed it, rather than only in a subscriber log line.\n <p className=\"rounded-md border border-status-error-border bg-status-error-bg p-3 text-status-error-text\">\n {t(\n 'channel_discord.aiAutoReply.lastError',\n 'The last auto-reply attempt on this channel produced nothing: {reason}',\n { reason: settings.aiAutoReplyLastError },\n )}\n {settings.aiAutoReplyLastErrorAt ? (\n <span className=\"ml-1\">\n {t('channel_discord.aiAutoReply.lastErrorAt', '(first seen {at})', {\n at: new Date(settings.aiAutoReplyLastErrorAt).toLocaleString(),\n })}\n </span>\n ) : null}\n </p>\n ) : null}\n </div>\n }\n />\n </PageBody>\n </Page>\n )\n}\n"],
5
+ "mappings": "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",
6
+ "names": []
7
+ }
@@ -0,0 +1,14 @@
1
+ import { CHANNEL_DISCORD_CONFIGURE_FEATURE } from "../../../../../lib/ai-features.js";
2
+ const metadata = {
3
+ requireAuth: true,
4
+ requireFeatures: [CHANNEL_DISCORD_CONFIGURE_FEATURE],
5
+ titleKey: "channel_discord.aiAutoReply.page.title",
6
+ title: "Discord AI auto-reply",
7
+ navHidden: true
8
+ };
9
+ var page_meta_default = metadata;
10
+ export {
11
+ page_meta_default as default,
12
+ metadata
13
+ };
14
+ //# sourceMappingURL=page.meta.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../../../../../../src/modules/channel_discord/backend/channel_discord/channels/%5Bid%5D/ai-auto-reply/page.meta.ts"],
4
+ "sourcesContent": ["import type { PageMetadata } from '@open-mercato/shared/modules/registry'\nimport { CHANNEL_DISCORD_CONFIGURE_FEATURE } from '../../../../../lib/ai-features'\n\n/**\n * Settings surface for one Discord channel's AI auto-reply. Guarded by the same\n * feature that gates connecting the bot \u2014 arming an agent to answer strangers on\n * the tenant's behalf is a configuration act, not a viewing one \u2014 and kept out of\n * the sidebar, because it is reached from the channel it configures.\n */\nexport const metadata: PageMetadata = {\n requireAuth: true,\n requireFeatures: [CHANNEL_DISCORD_CONFIGURE_FEATURE],\n titleKey: 'channel_discord.aiAutoReply.page.title',\n title: 'Discord AI auto-reply',\n navHidden: true,\n}\n\nexport default metadata\n"],
5
+ "mappings": "AACA,SAAS,yCAAyC;AAQ3C,MAAM,WAAyB;AAAA,EACpC,aAAa;AAAA,EACb,iBAAiB,CAAC,iCAAiC;AAAA,EACnD,UAAU;AAAA,EACV,OAAO;AAAA,EACP,WAAW;AACb;AAEA,IAAO,oBAAQ;",
6
+ "names": []
7
+ }
@@ -0,0 +1,142 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { createRequestContainer } from "@open-mercato/shared/lib/di/container";
3
+ import { createLogger } from "@open-mercato/shared/lib/logger";
4
+ import { createCredentialsService } from "@open-mercato/core/modules/integrations/lib/credentials-service";
5
+ import { createIntegrationLogService } from "@open-mercato/core/modules/integrations/lib/log-service";
6
+ import { parseDiscordCredentialsOrThrow } from "./lib/credentials.js";
7
+ import { getDiscordRestClient } from "./lib/discord-rest.js";
8
+ import { applyDiscordEnvPreset } from "./lib/preset.js";
9
+ import { assertInboundDeliverable } from "./lib/queue-strategy.js";
10
+ import { DISCORD_DEFAULT_SLASH_COMMANDS, parseSlashCommandDefinitions } from "./lib/slash-commands.js";
11
+ import gatewayHandle, { CHANNEL_DISCORD_GATEWAY_QUEUE } from "./workers/discord-gateway.js";
12
+ const logger = createLogger("channel_discord").child({ component: "cli" });
13
+ function parseFlagsAndValues(args) {
14
+ const flags = /* @__PURE__ */ new Set();
15
+ const values = {};
16
+ for (let index = 0; index < args.length; index += 1) {
17
+ const token = args[index];
18
+ if (!token?.startsWith("-")) continue;
19
+ const key = token.replace(/^-+/, "");
20
+ const next = args[index + 1];
21
+ if (next && !next.startsWith("-")) {
22
+ values[key] = next;
23
+ index += 1;
24
+ } else {
25
+ flags.add(key);
26
+ }
27
+ }
28
+ return { flags, values };
29
+ }
30
+ const startGateway = {
31
+ command: "start-gateway",
32
+ async run(rest) {
33
+ const { values } = parseFlagsAndValues(rest);
34
+ const tenantId = values.tenant ?? values.tenantId ?? values.t;
35
+ const refreshSeconds = Number.isFinite(Number(values.refresh ?? values.r)) ? Math.max(0, Number(values.refresh ?? values.r)) : 60;
36
+ assertInboundDeliverable(process.env.QUEUE_STRATEGY);
37
+ logger.info("starting Discord gateway bridge", {
38
+ queue: CHANNEL_DISCORD_GATEWAY_QUEUE,
39
+ tenantId: tenantId ?? "all",
40
+ refreshSeconds
41
+ });
42
+ const container = await createRequestContainer();
43
+ const { createModuleQueue, runWorker } = await import("@open-mercato/queue");
44
+ const jobPayload = tenantId ? { tenantId } : {};
45
+ const handler = async (job) => {
46
+ await gatewayHandle(job, { resolve: (name) => container.resolve(name) });
47
+ };
48
+ await runWorker({
49
+ queueName: CHANNEL_DISCORD_GATEWAY_QUEUE,
50
+ handler,
51
+ concurrency: 1,
52
+ gracefulShutdown: true,
53
+ background: true
54
+ });
55
+ const queue = createModuleQueue(CHANNEL_DISCORD_GATEWAY_QUEUE, { concurrency: 1 });
56
+ await queue.enqueue({ ...jobPayload, reason: "startup" });
57
+ logger.info("enqueued gateway bootstrap job");
58
+ if (refreshSeconds > 0) {
59
+ setInterval(() => {
60
+ queue.enqueue({ ...jobPayload, reason: "refresh" }).catch((err) => logger.warn("failed to enqueue gateway refresh job", { err }));
61
+ }, refreshSeconds * 1e3);
62
+ }
63
+ logger.info("Discord gateway bridge running \u2014 press Ctrl+C to stop");
64
+ await new Promise(() => {
65
+ });
66
+ }
67
+ };
68
+ const configureFromEnv = {
69
+ command: "configure-from-env",
70
+ async run(rest) {
71
+ const { flags, values } = parseFlagsAndValues(rest);
72
+ const tenantId = values.tenant ?? values.tenantId;
73
+ const organizationId = values.org ?? values.organization ?? values.organizationId;
74
+ if (!tenantId || !organizationId) {
75
+ logger.error("configure-from-env requires --tenant <tenantId> --org <organizationId>");
76
+ throw new Error("[internal] channel_discord configure-from-env requires --tenant and --org");
77
+ }
78
+ const container = await createRequestContainer();
79
+ const em = container.resolve("em");
80
+ const result = await applyDiscordEnvPreset({
81
+ credentialsService: createCredentialsService(em),
82
+ integrationLogService: createIntegrationLogService(em),
83
+ scope: { tenantId, organizationId },
84
+ force: flags.has("force")
85
+ });
86
+ if (result.status === "configured") {
87
+ logger.info("Discord integration preconfigured from environment", { tenantId, organizationId });
88
+ } else {
89
+ logger.info("Discord env preconfiguration skipped", { tenantId, organizationId, reason: result.reason });
90
+ }
91
+ }
92
+ };
93
+ const registerSlashCommands = {
94
+ command: "register-slash-commands",
95
+ async run(rest) {
96
+ const { values } = parseFlagsAndValues(rest);
97
+ const tenantId = values.tenant ?? values.tenantId;
98
+ const organizationId = values.org ?? values.organization ?? values.organizationId;
99
+ if (!tenantId || !organizationId) {
100
+ logger.error("register-slash-commands requires --tenant <tenantId> --org <organizationId>");
101
+ throw new Error("[internal] channel_discord register-slash-commands requires --tenant and --org");
102
+ }
103
+ const container = await createRequestContainer();
104
+ const em = container.resolve("em");
105
+ const credentials = await createCredentialsService(em).resolve("channel_discord", {
106
+ tenantId,
107
+ organizationId
108
+ });
109
+ if (!credentials) {
110
+ throw new Error(
111
+ `[internal] No Discord credentials for tenant ${tenantId} / organization ${organizationId}. Connect the channel in /backend/integrations first.`
112
+ );
113
+ }
114
+ const parsed = parseDiscordCredentialsOrThrow(credentials);
115
+ const guildId = values.guild ?? values.guildId ?? parsed.guildId;
116
+ if (!guildId) {
117
+ throw new Error(
118
+ "[internal] channel_discord register-slash-commands needs a guild: set the guildId credential or pass --guild <guildId>."
119
+ );
120
+ }
121
+ const commands = values.commands ? parseSlashCommandDefinitions(JSON.parse(await readFile(values.commands, "utf-8"))) : DISCORD_DEFAULT_SLASH_COMMANDS;
122
+ await getDiscordRestClient().registerGuildCommands(
123
+ { botToken: parsed.botToken },
124
+ parsed.applicationId,
125
+ guildId,
126
+ commands
127
+ );
128
+ logger.info("registered Discord guild slash commands", {
129
+ tenantId,
130
+ organizationId,
131
+ guildId,
132
+ commands: commands.map((command) => command.name)
133
+ });
134
+ }
135
+ };
136
+ const channelDiscordCliCommands = [startGateway, configureFromEnv, registerSlashCommands];
137
+ var cli_default = channelDiscordCliCommands;
138
+ export {
139
+ cli_default as default,
140
+ parseFlagsAndValues
141
+ };
142
+ //# sourceMappingURL=cli.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../src/modules/channel_discord/cli.ts"],
4
+ "sourcesContent": ["import { readFile } from 'node:fs/promises'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport type { ModuleCli } from '@open-mercato/shared/modules/registry'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\nimport { createCredentialsService } from '@open-mercato/core/modules/integrations/lib/credentials-service'\nimport { createIntegrationLogService } from '@open-mercato/core/modules/integrations/lib/log-service'\nimport { parseDiscordCredentialsOrThrow } from './lib/credentials'\nimport { getDiscordRestClient } from './lib/discord-rest'\nimport { applyDiscordEnvPreset } from './lib/preset'\nimport { assertInboundDeliverable } from './lib/queue-strategy'\nimport { DISCORD_DEFAULT_SLASH_COMMANDS, parseSlashCommandDefinitions } from './lib/slash-commands'\nimport gatewayHandle, { CHANNEL_DISCORD_GATEWAY_QUEUE } from './workers/discord-gateway'\n\nconst logger = createLogger('channel_discord').child({ component: 'cli' })\n\n/**\n * Split `--key value` pairs from bare `--flag` switches so a boolean flag does\n * not shift the positional pairing of the remaining arguments.\n */\nexport function parseFlagsAndValues(args: string[]): { flags: Set<string>; values: Record<string, string> } {\n const flags = new Set<string>()\n const values: Record<string, string> = {}\n for (let index = 0; index < args.length; index += 1) {\n const token = args[index]\n if (!token?.startsWith('-')) continue\n const key = token.replace(/^-+/, '')\n const next = args[index + 1]\n if (next && !next.startsWith('-')) {\n values[key] = next\n index += 1\n } else {\n flags.add(key)\n }\n }\n return { flags, values }\n}\n\n/**\n * Start the Discord Gateway bridge.\n *\n * WHY THIS EXISTS: the provider advertises `realtimePush: true`, so the hub's\n * polling scheduler skips Discord channels \u2014 inbound relies entirely on this\n * long-running gateway worker. Nothing enqueues the `channel_discord_gateway`\n * job automatically (no hub \"channel connected\" event exists), so inbound is\n * dead until an operator starts this process. This command enqueues the initial\n * bootstrap job, runs the worker, and (by default) re-enqueues a refresh job on\n * an interval so newly connected channels are picked up and deactivated /\n * soft-deleted channels are reconciled away (sockets closed).\n *\n * The refresh job is a reconciler, not a re-connector: the worker leaves every\n * channel whose gateway session is still running untouched, so a short interval\n * costs one query per tick and never disturbs a healthy socket.\n *\n * Usage:\n * mercato channel_discord start-gateway [--tenant <tenantId>] [--refresh <seconds>]\n *\n * `--refresh 0` disables the periodic refresh (single reconciliation at startup).\n * Set `OM_CHANNEL_DISCORD_GATEWAY_DISABLED=1` to make the worker a no-op.\n */\nconst startGateway: ModuleCli = {\n command: 'start-gateway',\n async run(rest: string[]) {\n const { values } = parseFlagsAndValues(rest)\n const tenantId = values.tenant ?? values.tenantId ?? values.t\n const refreshSeconds = Number.isFinite(Number(values.refresh ?? values.r))\n ? Math.max(0, Number(values.refresh ?? values.r))\n : 60\n\n assertInboundDeliverable(process.env.QUEUE_STRATEGY)\n\n logger.info('starting Discord gateway bridge', {\n queue: CHANNEL_DISCORD_GATEWAY_QUEUE,\n tenantId: tenantId ?? 'all',\n refreshSeconds,\n })\n\n const container = await createRequestContainer()\n\n const { createModuleQueue, runWorker } = await import('@open-mercato/queue')\n\n const jobPayload = tenantId ? { tenantId } : {}\n\n // The worker context only needs `.resolve`; the queue runner's own ctx does\n // not carry the DI container, so we close over the request container here\n // (mirrors workflows' `start-worker`).\n const handler = async (job: { payload?: Record<string, unknown> }): Promise<void> => {\n await gatewayHandle(job as never, { resolve: (name: string) => container.resolve(name) } as never)\n }\n\n await runWorker({\n queueName: CHANNEL_DISCORD_GATEWAY_QUEUE,\n handler: handler as never,\n concurrency: 1,\n gracefulShutdown: true,\n background: true,\n })\n\n const queue = createModuleQueue<Record<string, unknown>>(CHANNEL_DISCORD_GATEWAY_QUEUE, { concurrency: 1 })\n await queue.enqueue({ ...jobPayload, reason: 'startup' })\n logger.info('enqueued gateway bootstrap job')\n\n if (refreshSeconds > 0) {\n setInterval(() => {\n queue\n .enqueue({ ...jobPayload, reason: 'refresh' })\n .catch((err) => logger.warn('failed to enqueue gateway refresh job', { err }))\n }, refreshSeconds * 1000)\n }\n\n logger.info('Discord gateway bridge running \u2014 press Ctrl+C to stop')\n // Keep the process alive so the sockets + refresh interval persist.\n await new Promise<void>(() => {})\n },\n}\n\n/**\n * Rerun the provider-owned env preconfiguration for one tenant + organization.\n *\n * `setup.ts` applies the same preset when a tenant is created; this command\n * exists so an operator can apply it to tenants that already existed when the\n * `OM_CHANNEL_DISCORD_*` vars were introduced, or re-apply it after rotating the\n * bot token (with `--force`). Idempotent: without `--force` an existing\n * credential record is left untouched.\n */\nconst configureFromEnv: ModuleCli = {\n command: 'configure-from-env',\n async run(rest: string[]) {\n const { flags, values } = parseFlagsAndValues(rest)\n const tenantId = values.tenant ?? values.tenantId\n const organizationId = values.org ?? values.organization ?? values.organizationId\n\n if (!tenantId || !organizationId) {\n logger.error('configure-from-env requires --tenant <tenantId> --org <organizationId>')\n throw new Error('[internal] channel_discord configure-from-env requires --tenant and --org')\n }\n\n const container = await createRequestContainer()\n const em = container.resolve('em') as EntityManager\n const result = await applyDiscordEnvPreset({\n credentialsService: createCredentialsService(em),\n integrationLogService: createIntegrationLogService(em),\n scope: { tenantId, organizationId },\n force: flags.has('force'),\n })\n\n if (result.status === 'configured') {\n logger.info('Discord integration preconfigured from environment', { tenantId, organizationId })\n } else {\n logger.info('Discord env preconfiguration skipped', { tenantId, organizationId, reason: result.reason })\n }\n },\n}\n\n/**\n * Register the provider's application (slash) commands against one guild.\n *\n * WHY IT EXISTS: a slash command only appears in Discord once the application\n * has registered it. Nothing does that automatically \u2014 registration is a\n * per-guild, operator-scoped decision \u2014 so without this command the Interactions\n * endpoint is reachable but nobody can produce an interaction to send to it.\n *\n * Guild-scoped (`PUT /applications/{app}/guilds/{guild}/commands`) rather than\n * global on purpose: guild registrations take effect immediately, while global\n * ones take up to an hour to propagate, and the provider already recommends\n * scoping the bot to one server.\n *\n * The call REPLACES the guild's command list for this application, so it is\n * idempotent \u2014 re-running it converges rather than accumulating duplicates.\n *\n * Usage:\n * mercato channel_discord register-slash-commands --tenant <id> --org <id>\n * [--guild <guildId>] [--commands <path-to-json>]\n *\n * `--guild` overrides the `guildId` credential; `--commands` registers an\n * operator-authored definition array instead of the shipped default.\n */\nconst registerSlashCommands: ModuleCli = {\n command: 'register-slash-commands',\n async run(rest: string[]) {\n const { values } = parseFlagsAndValues(rest)\n const tenantId = values.tenant ?? values.tenantId\n const organizationId = values.org ?? values.organization ?? values.organizationId\n\n if (!tenantId || !organizationId) {\n logger.error('register-slash-commands requires --tenant <tenantId> --org <organizationId>')\n throw new Error('[internal] channel_discord register-slash-commands requires --tenant and --org')\n }\n\n const container = await createRequestContainer()\n const em = container.resolve('em') as EntityManager\n const credentials = await createCredentialsService(em).resolve('channel_discord', {\n tenantId,\n organizationId,\n })\n if (!credentials) {\n throw new Error(\n `[internal] No Discord credentials for tenant ${tenantId} / organization ${organizationId}. ` +\n 'Connect the channel in /backend/integrations first.',\n )\n }\n const parsed = parseDiscordCredentialsOrThrow(credentials)\n\n const guildId = values.guild ?? values.guildId ?? parsed.guildId\n if (!guildId) {\n throw new Error(\n '[internal] channel_discord register-slash-commands needs a guild: set the guildId credential ' +\n 'or pass --guild <guildId>.',\n )\n }\n\n const commands = values.commands\n ? parseSlashCommandDefinitions(JSON.parse(await readFile(values.commands, 'utf-8')))\n : DISCORD_DEFAULT_SLASH_COMMANDS\n\n await getDiscordRestClient().registerGuildCommands(\n { botToken: parsed.botToken },\n parsed.applicationId,\n guildId,\n commands,\n )\n\n logger.info('registered Discord guild slash commands', {\n tenantId,\n organizationId,\n guildId,\n commands: commands.map((command) => command.name),\n })\n },\n}\n\nconst channelDiscordCliCommands = [startGateway, configureFromEnv, registerSlashCommands]\n\nexport default channelDiscordCliCommands\n"],
5
+ "mappings": "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",
6
+ "names": []
7
+ }
@@ -0,0 +1,116 @@
1
+ import { z } from "zod";
2
+ import { registerCommand } from "@open-mercato/shared/lib/commands";
3
+ import { createLogger } from "@open-mercato/shared/lib/logger";
4
+ import { findOneWithDecryption } from "@open-mercato/shared/lib/encryption/find";
5
+ import { Message } from "@open-mercato/core/modules/messages/data/entities";
6
+ import {
7
+ CHANNEL_DISCORD_AI_PROPOSAL_APPROVE_COMMAND_ID,
8
+ CHANNEL_DISCORD_AI_PROPOSAL_DISMISS_COMMAND_ID
9
+ } from "../lib/ai-proposal-contract.js";
10
+ import { CHANNEL_DISCORD_AI_PROPOSAL_MESSAGE_TYPE } from "../message-types.js";
11
+ const logger = createLogger("channel_discord").child({ component: "ai-reply-proposal" });
12
+ const proposalActionInputSchema = z.object({
13
+ messageId: z.string().uuid(),
14
+ actionId: z.string().min(1)
15
+ }).passthrough();
16
+ function requireScope(auth) {
17
+ const userId = auth?.sub;
18
+ const tenantId = auth?.tenantId;
19
+ if (!userId || !tenantId) {
20
+ throw new Error("[internal] channel_discord AI proposal action requires an authenticated actor");
21
+ }
22
+ return { tenantId, organizationId: auth?.orgId ?? null, userId };
23
+ }
24
+ async function loadProposal(em, messageId, scope) {
25
+ const proposal = await findOneWithDecryption(
26
+ em,
27
+ Message,
28
+ { id: messageId, tenantId: scope.tenantId, deletedAt: null },
29
+ void 0,
30
+ { tenantId: scope.tenantId, organizationId: scope.organizationId }
31
+ );
32
+ if (!proposal) throw new Error("[internal] AI reply proposal not found");
33
+ if (proposal.type !== CHANNEL_DISCORD_AI_PROPOSAL_MESSAGE_TYPE) {
34
+ throw new Error("[internal] Message is not a Discord AI reply proposal");
35
+ }
36
+ return proposal;
37
+ }
38
+ const approveProposalCommand = {
39
+ id: CHANNEL_DISCORD_AI_PROPOSAL_APPROVE_COMMAND_ID,
40
+ // A message posted into a Discord server cannot be recalled by undoing a row
41
+ // here, so the command declines to pretend otherwise.
42
+ isUndoable: false,
43
+ async execute(rawInput, ctx) {
44
+ const input = proposalActionInputSchema.parse(rawInput);
45
+ const scope = requireScope(ctx.auth);
46
+ const em = ctx.container.resolve("em").fork();
47
+ const proposal = await loadProposal(em, input.messageId, scope);
48
+ const inboundMessageId = proposal.sourceEntityId;
49
+ if (!inboundMessageId) {
50
+ throw new Error("[internal] AI reply proposal has no source message to reply to");
51
+ }
52
+ const inbound = await findOneWithDecryption(
53
+ em,
54
+ Message,
55
+ { id: inboundMessageId, tenantId: scope.tenantId, deletedAt: null },
56
+ void 0,
57
+ { tenantId: scope.tenantId, organizationId: scope.organizationId }
58
+ );
59
+ if (!inbound) {
60
+ throw new Error("[internal] The message this proposal answers no longer exists");
61
+ }
62
+ const body = (proposal.body ?? "").trim();
63
+ if (!body) {
64
+ throw new Error("[internal] AI reply proposal has an empty body");
65
+ }
66
+ const commandBus = ctx.container.resolve("commandBus");
67
+ const composeResult = await commandBus.execute("messages.messages.compose", {
68
+ input: {
69
+ type: inbound.type,
70
+ visibility: "public",
71
+ subject: (inbound.subject ?? "Discord reply").toString().slice(0, 200) || "Discord reply",
72
+ body,
73
+ bodyFormat: "markdown",
74
+ priority: "normal",
75
+ sendViaEmail: false,
76
+ parentMessageId: inbound.threadId ?? inbound.id,
77
+ isDraft: false,
78
+ tenantId: scope.tenantId,
79
+ organizationId: scope.organizationId,
80
+ userId: scope.userId
81
+ },
82
+ ctx: {
83
+ container: ctx.container,
84
+ auth: ctx.auth ?? null,
85
+ organizationScope: null,
86
+ selectedOrganizationId: scope.organizationId,
87
+ organizationIds: scope.organizationId ? [scope.organizationId] : null
88
+ }
89
+ });
90
+ const sent = composeResult.result;
91
+ logger.info("AI reply proposal approved and sent", {
92
+ proposalId: proposal.id,
93
+ sentMessageId: sent.id
94
+ });
95
+ return { ok: true, sentMessageId: sent.id, threadId: sent.threadId ?? null };
96
+ }
97
+ };
98
+ const dismissProposalCommand = {
99
+ id: CHANNEL_DISCORD_AI_PROPOSAL_DISMISS_COMMAND_ID,
100
+ isUndoable: false,
101
+ async execute(rawInput, ctx) {
102
+ const input = proposalActionInputSchema.parse(rawInput);
103
+ const scope = requireScope(ctx.auth);
104
+ const em = ctx.container.resolve("em").fork();
105
+ const proposal = await loadProposal(em, input.messageId, scope);
106
+ logger.info("AI reply proposal dismissed", { proposalId: proposal.id });
107
+ return { ok: true, dismissed: true };
108
+ }
109
+ };
110
+ registerCommand(approveProposalCommand);
111
+ registerCommand(dismissProposalCommand);
112
+ export {
113
+ approveProposalCommand,
114
+ dismissProposalCommand
115
+ };
116
+ //# sourceMappingURL=ai-reply-proposal.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../../src/modules/channel_discord/commands/ai-reply-proposal.ts"],
4
+ "sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\nimport { z } from 'zod'\nimport type { CommandBus, CommandHandler } from '@open-mercato/shared/lib/commands'\nimport { registerCommand } from '@open-mercato/shared/lib/commands'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\nimport { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport { Message } from '@open-mercato/core/modules/messages/data/entities'\nimport {\n CHANNEL_DISCORD_AI_PROPOSAL_APPROVE_COMMAND_ID,\n CHANNEL_DISCORD_AI_PROPOSAL_DISMISS_COMMAND_ID,\n} from '../lib/ai-proposal-contract'\nimport { CHANNEL_DISCORD_AI_PROPOSAL_MESSAGE_TYPE } from '../message-types'\n\nconst logger = createLogger('channel_discord').child({ component: 'ai-reply-proposal' })\n\n/**\n * Commands behind the `complex`-tier approval card (issue #4778).\n *\n * `messages.actions.execute` dispatches these after it has atomically claimed\n * the terminal action, so both handlers can assume they run at most once per\n * proposal. It passes `messageId` / `actionId` and the acting operator in\n * `ctx.auth` \u2014 everything else these commands need is read from the stored\n * proposal, never from the request body, so a caller cannot redirect the send.\n */\nconst proposalActionInputSchema = z\n .object({\n messageId: z.string().uuid(),\n actionId: z.string().min(1),\n })\n .passthrough()\n\ntype ProposalActionInput = z.infer<typeof proposalActionInputSchema>\n\nexport type ApproveProposalResult = { ok: true; sentMessageId: string; threadId: string | null }\nexport type DismissProposalResult = { ok: true; dismissed: true }\n\ntype ResolvedScope = { tenantId: string; organizationId: string | null; userId: string }\n\nfunction requireScope(auth: { sub?: string; tenantId?: string | null; orgId?: string | null } | null): ResolvedScope {\n const userId = auth?.sub\n const tenantId = auth?.tenantId\n if (!userId || !tenantId) {\n throw new Error('[internal] channel_discord AI proposal action requires an authenticated actor')\n }\n return { tenantId, organizationId: auth?.orgId ?? null, userId }\n}\n\nasync function loadProposal(\n em: EntityManager,\n messageId: string,\n scope: ResolvedScope,\n): Promise<Message> {\n const proposal = await findOneWithDecryption(\n em,\n Message,\n { id: messageId, tenantId: scope.tenantId, deletedAt: null },\n undefined,\n { tenantId: scope.tenantId, organizationId: scope.organizationId },\n )\n if (!proposal) throw new Error('[internal] AI reply proposal not found')\n if (proposal.type !== CHANNEL_DISCORD_AI_PROPOSAL_MESSAGE_TYPE) {\n // The action is only ever rendered on our own message type; a mismatch means\n // the id was swapped, so refuse rather than sending an unrelated message body\n // into a Discord server.\n throw new Error('[internal] Message is not a Discord AI reply proposal')\n }\n return proposal\n}\n\nconst approveProposalCommand: CommandHandler<ProposalActionInput, ApproveProposalResult> = {\n id: CHANNEL_DISCORD_AI_PROPOSAL_APPROVE_COMMAND_ID,\n // A message posted into a Discord server cannot be recalled by undoing a row\n // here, so the command declines to pretend otherwise.\n isUndoable: false,\n async execute(rawInput, ctx) {\n const input = proposalActionInputSchema.parse(rawInput)\n const scope = requireScope(ctx.auth)\n const em = (ctx.container.resolve('em') as EntityManager).fork()\n\n const proposal = await loadProposal(em, input.messageId, scope)\n const inboundMessageId = proposal.sourceEntityId\n if (!inboundMessageId) {\n throw new Error('[internal] AI reply proposal has no source message to reply to')\n }\n\n const inbound = await findOneWithDecryption(\n em,\n Message,\n { id: inboundMessageId, tenantId: scope.tenantId, deletedAt: null },\n undefined,\n { tenantId: scope.tenantId, organizationId: scope.organizationId },\n )\n if (!inbound) {\n throw new Error('[internal] The message this proposal answers no longer exists')\n }\n\n const body = (proposal.body ?? '').trim()\n if (!body) {\n throw new Error('[internal] AI reply proposal has an empty body')\n }\n\n const commandBus = ctx.container.resolve('commandBus') as CommandBus\n // Same generic outbound path the easy tier uses: compose a public reply in\n // the thread and let the hub's outbound bridge deliver it. Attribution is the\n // approving operator, not the bot \u2014 a human decided to send this.\n const composeResult = await commandBus.execute<\n Record<string, unknown>,\n { id: string; threadId: string | null }\n >('messages.messages.compose', {\n input: {\n type: inbound.type,\n visibility: 'public' as const,\n subject: (inbound.subject ?? 'Discord reply').toString().slice(0, 200) || 'Discord reply',\n body,\n bodyFormat: 'markdown' as const,\n priority: 'normal' as const,\n sendViaEmail: false,\n parentMessageId: inbound.threadId ?? inbound.id,\n isDraft: false,\n tenantId: scope.tenantId,\n organizationId: scope.organizationId,\n userId: scope.userId,\n },\n ctx: {\n container: ctx.container,\n auth: ctx.auth ?? null,\n organizationScope: null,\n selectedOrganizationId: scope.organizationId,\n organizationIds: scope.organizationId ? [scope.organizationId] : null,\n },\n })\n\n const sent = composeResult.result\n logger.info('AI reply proposal approved and sent', {\n proposalId: proposal.id,\n sentMessageId: sent.id,\n })\n return { ok: true, sentMessageId: sent.id, threadId: sent.threadId ?? null }\n },\n}\n\nconst dismissProposalCommand: CommandHandler<ProposalActionInput, DismissProposalResult> = {\n id: CHANNEL_DISCORD_AI_PROPOSAL_DISMISS_COMMAND_ID,\n isUndoable: false,\n async execute(rawInput, ctx) {\n const input = proposalActionInputSchema.parse(rawInput)\n const scope = requireScope(ctx.auth)\n const em = (ctx.container.resolve('em') as EntityManager).fork()\n\n // Nothing to write: `messages.actions.execute` has already recorded the\n // terminal action, which is what \"dismissed\" means. The load is the guard \u2014\n // it keeps the dismiss action from acknowledging a message that is not ours.\n const proposal = await loadProposal(em, input.messageId, scope)\n logger.info('AI reply proposal dismissed', { proposalId: proposal.id })\n return { ok: true, dismissed: true }\n },\n}\n\nregisterCommand(approveProposalCommand as CommandHandler<unknown, unknown>)\nregisterCommand(dismissProposalCommand as CommandHandler<unknown, unknown>)\n\nexport { approveProposalCommand, dismissProposalCommand }\n"],
5
+ "mappings": "AACA,SAAS,SAAS;AAElB,SAAS,uBAAuB;AAChC,SAAS,oBAAoB;AAC7B,SAAS,6BAA6B;AACtC,SAAS,eAAe;AACxB;AAAA,EACE;AAAA,EACA;AAAA,OACK;AACP,SAAS,gDAAgD;AAEzD,MAAM,SAAS,aAAa,iBAAiB,EAAE,MAAM,EAAE,WAAW,oBAAoB,CAAC;AAWvF,MAAM,4BAA4B,EAC/B,OAAO;AAAA,EACN,WAAW,EAAE,OAAO,EAAE,KAAK;AAAA,EAC3B,UAAU,EAAE,OAAO,EAAE,IAAI,CAAC;AAC5B,CAAC,EACA,YAAY;AASf,SAAS,aAAa,MAA+F;AACnH,QAAM,SAAS,MAAM;AACrB,QAAM,WAAW,MAAM;AACvB,MAAI,CAAC,UAAU,CAAC,UAAU;AACxB,UAAM,IAAI,MAAM,+EAA+E;AAAA,EACjG;AACA,SAAO,EAAE,UAAU,gBAAgB,MAAM,SAAS,MAAM,OAAO;AACjE;AAEA,eAAe,aACb,IACA,WACA,OACkB;AAClB,QAAM,WAAW,MAAM;AAAA,IACrB;AAAA,IACA;AAAA,IACA,EAAE,IAAI,WAAW,UAAU,MAAM,UAAU,WAAW,KAAK;AAAA,IAC3D;AAAA,IACA,EAAE,UAAU,MAAM,UAAU,gBAAgB,MAAM,eAAe;AAAA,EACnE;AACA,MAAI,CAAC,SAAU,OAAM,IAAI,MAAM,wCAAwC;AACvE,MAAI,SAAS,SAAS,0CAA0C;AAI9D,UAAM,IAAI,MAAM,uDAAuD;AAAA,EACzE;AACA,SAAO;AACT;AAEA,MAAM,yBAAqF;AAAA,EACzF,IAAI;AAAA;AAAA;AAAA,EAGJ,YAAY;AAAA,EACZ,MAAM,QAAQ,UAAU,KAAK;AAC3B,UAAM,QAAQ,0BAA0B,MAAM,QAAQ;AACtD,UAAM,QAAQ,aAAa,IAAI,IAAI;AACnC,UAAM,KAAM,IAAI,UAAU,QAAQ,IAAI,EAAoB,KAAK;AAE/D,UAAM,WAAW,MAAM,aAAa,IAAI,MAAM,WAAW,KAAK;AAC9D,UAAM,mBAAmB,SAAS;AAClC,QAAI,CAAC,kBAAkB;AACrB,YAAM,IAAI,MAAM,gEAAgE;AAAA,IAClF;AAEA,UAAM,UAAU,MAAM;AAAA,MACpB;AAAA,MACA;AAAA,MACA,EAAE,IAAI,kBAAkB,UAAU,MAAM,UAAU,WAAW,KAAK;AAAA,MAClE;AAAA,MACA,EAAE,UAAU,MAAM,UAAU,gBAAgB,MAAM,eAAe;AAAA,IACnE;AACA,QAAI,CAAC,SAAS;AACZ,YAAM,IAAI,MAAM,+DAA+D;AAAA,IACjF;AAEA,UAAM,QAAQ,SAAS,QAAQ,IAAI,KAAK;AACxC,QAAI,CAAC,MAAM;AACT,YAAM,IAAI,MAAM,gDAAgD;AAAA,IAClE;AAEA,UAAM,aAAa,IAAI,UAAU,QAAQ,YAAY;AAIrD,UAAM,gBAAgB,MAAM,WAAW,QAGrC,6BAA6B;AAAA,MAC7B,OAAO;AAAA,QACL,MAAM,QAAQ;AAAA,QACd,YAAY;AAAA,QACZ,UAAU,QAAQ,WAAW,iBAAiB,SAAS,EAAE,MAAM,GAAG,GAAG,KAAK;AAAA,QAC1E;AAAA,QACA,YAAY;AAAA,QACZ,UAAU;AAAA,QACV,cAAc;AAAA,QACd,iBAAiB,QAAQ,YAAY,QAAQ;AAAA,QAC7C,SAAS;AAAA,QACT,UAAU,MAAM;AAAA,QAChB,gBAAgB,MAAM;AAAA,QACtB,QAAQ,MAAM;AAAA,MAChB;AAAA,MACA,KAAK;AAAA,QACH,WAAW,IAAI;AAAA,QACf,MAAM,IAAI,QAAQ;AAAA,QAClB,mBAAmB;AAAA,QACnB,wBAAwB,MAAM;AAAA,QAC9B,iBAAiB,MAAM,iBAAiB,CAAC,MAAM,cAAc,IAAI;AAAA,MACnE;AAAA,IACF,CAAC;AAED,UAAM,OAAO,cAAc;AAC3B,WAAO,KAAK,uCAAuC;AAAA,MACjD,YAAY,SAAS;AAAA,MACrB,eAAe,KAAK;AAAA,IACtB,CAAC;AACD,WAAO,EAAE,IAAI,MAAM,eAAe,KAAK,IAAI,UAAU,KAAK,YAAY,KAAK;AAAA,EAC7E;AACF;AAEA,MAAM,yBAAqF;AAAA,EACzF,IAAI;AAAA,EACJ,YAAY;AAAA,EACZ,MAAM,QAAQ,UAAU,KAAK;AAC3B,UAAM,QAAQ,0BAA0B,MAAM,QAAQ;AACtD,UAAM,QAAQ,aAAa,IAAI,IAAI;AACnC,UAAM,KAAM,IAAI,UAAU,QAAQ,IAAI,EAAoB,KAAK;AAK/D,UAAM,WAAW,MAAM,aAAa,IAAI,MAAM,WAAW,KAAK;AAC9D,WAAO,KAAK,+BAA+B,EAAE,YAAY,SAAS,GAAG,CAAC;AACtE,WAAO,EAAE,IAAI,MAAM,WAAW,KAAK;AAAA,EACrC;AACF;AAEA,gBAAgB,sBAA0D;AAC1E,gBAAgB,sBAA0D;",
6
+ "names": []
7
+ }