@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.
- package/.turbo/turbo-build.log +2 -0
- package/build.mjs +7 -0
- package/dist/index.js +5 -0
- package/dist/index.js.map +7 -0
- package/dist/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-001.spec.js +86 -0
- package/dist/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-001.spec.js.map +7 -0
- package/dist/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-002.spec.js +39 -0
- package/dist/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-002.spec.js.map +7 -0
- package/dist/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-003.spec.js +97 -0
- package/dist/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-003.spec.js.map +7 -0
- package/dist/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-004.spec.js +65 -0
- package/dist/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-004.spec.js.map +7 -0
- package/dist/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-005.spec.js +110 -0
- package/dist/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-005.spec.js.map +7 -0
- package/dist/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-006.spec.js +103 -0
- package/dist/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-006.spec.js.map +7 -0
- package/dist/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-007.spec.js +93 -0
- package/dist/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-007.spec.js.map +7 -0
- package/dist/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-008.spec.js +52 -0
- package/dist/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-008.spec.js.map +7 -0
- package/dist/modules/channel_discord/__integration__/helpers/discordSignature.js +46 -0
- package/dist/modules/channel_discord/__integration__/helpers/discordSignature.js.map +7 -0
- package/dist/modules/channel_discord/__integration__/helpers/eventsQueue.js +16 -0
- package/dist/modules/channel_discord/__integration__/helpers/eventsQueue.js.map +7 -0
- package/dist/modules/channel_discord/__integration__/meta.js +10 -0
- package/dist/modules/channel_discord/__integration__/meta.js.map +7 -0
- package/dist/modules/channel_discord/acl.js +15 -0
- package/dist/modules/channel_discord/acl.js.map +7 -0
- package/dist/modules/channel_discord/ai-agents.js +97 -0
- package/dist/modules/channel_discord/ai-agents.js.map +7 -0
- package/dist/modules/channel_discord/api/ai-auto-reply/channels/route.js +73 -0
- package/dist/modules/channel_discord/api/ai-auto-reply/channels/route.js.map +7 -0
- package/dist/modules/channel_discord/api/channels/[id]/ai-auto-reply/route.js +222 -0
- package/dist/modules/channel_discord/api/channels/[id]/ai-auto-reply/route.js.map +7 -0
- package/dist/modules/channel_discord/api/interactions/route.js +173 -0
- package/dist/modules/channel_discord/api/interactions/route.js.map +7 -0
- package/dist/modules/channel_discord/backend/channel_discord/channels/[id]/ai-auto-reply/page.js +196 -0
- package/dist/modules/channel_discord/backend/channel_discord/channels/[id]/ai-auto-reply/page.js.map +7 -0
- package/dist/modules/channel_discord/backend/channel_discord/channels/[id]/ai-auto-reply/page.meta.js +14 -0
- package/dist/modules/channel_discord/backend/channel_discord/channels/[id]/ai-auto-reply/page.meta.js.map +7 -0
- package/dist/modules/channel_discord/cli.js +142 -0
- package/dist/modules/channel_discord/cli.js.map +7 -0
- package/dist/modules/channel_discord/commands/ai-reply-proposal.js +116 -0
- package/dist/modules/channel_discord/commands/ai-reply-proposal.js.map +7 -0
- package/dist/modules/channel_discord/commands/update-ai-auto-reply.js +104 -0
- package/dist/modules/channel_discord/commands/update-ai-auto-reply.js.map +7 -0
- package/dist/modules/channel_discord/di.js +23 -0
- package/dist/modules/channel_discord/di.js.map +7 -0
- package/dist/modules/channel_discord/index.js +9 -0
- package/dist/modules/channel_discord/index.js.map +7 -0
- package/dist/modules/channel_discord/integration.js +96 -0
- package/dist/modules/channel_discord/integration.js.map +7 -0
- package/dist/modules/channel_discord/lib/adapter.js +248 -0
- package/dist/modules/channel_discord/lib/adapter.js.map +7 -0
- package/dist/modules/channel_discord/lib/ai-agent-directory.js +45 -0
- package/dist/modules/channel_discord/lib/ai-agent-directory.js.map +7 -0
- package/dist/modules/channel_discord/lib/ai-features.js +9 -0
- package/dist/modules/channel_discord/lib/ai-features.js.map +7 -0
- package/dist/modules/channel_discord/lib/ai-proposal-contract.js +9 -0
- package/dist/modules/channel_discord/lib/ai-proposal-contract.js.map +7 -0
- package/dist/modules/channel_discord/lib/ai-proposal.js +109 -0
- package/dist/modules/channel_discord/lib/ai-proposal.js.map +7 -0
- package/dist/modules/channel_discord/lib/ai-reply.js +96 -0
- package/dist/modules/channel_discord/lib/ai-reply.js.map +7 -0
- package/dist/modules/channel_discord/lib/ai-service-principal.js +70 -0
- package/dist/modules/channel_discord/lib/ai-service-principal.js.map +7 -0
- package/dist/modules/channel_discord/lib/capabilities.js +42 -0
- package/dist/modules/channel_discord/lib/capabilities.js.map +7 -0
- package/dist/modules/channel_discord/lib/channel-access.js +62 -0
- package/dist/modules/channel_discord/lib/channel-access.js.map +7 -0
- package/dist/modules/channel_discord/lib/channel-state-store.js +103 -0
- package/dist/modules/channel_discord/lib/channel-state-store.js.map +7 -0
- package/dist/modules/channel_discord/lib/convert-outbound.js +38 -0
- package/dist/modules/channel_discord/lib/convert-outbound.js.map +7 -0
- package/dist/modules/channel_discord/lib/credentials.js +61 -0
- package/dist/modules/channel_discord/lib/credentials.js.map +7 -0
- package/dist/modules/channel_discord/lib/discord-gateway-client.js +289 -0
- package/dist/modules/channel_discord/lib/discord-gateway-client.js.map +7 -0
- package/dist/modules/channel_discord/lib/discord-rest.js +203 -0
- package/dist/modules/channel_discord/lib/discord-rest.js.map +7 -0
- package/dist/modules/channel_discord/lib/failure-reason.js +30 -0
- package/dist/modules/channel_discord/lib/failure-reason.js.map +7 -0
- package/dist/modules/channel_discord/lib/gateway-bridge.js +42 -0
- package/dist/modules/channel_discord/lib/gateway-bridge.js.map +7 -0
- package/dist/modules/channel_discord/lib/health.js +37 -0
- package/dist/modules/channel_discord/lib/health.js.map +7 -0
- package/dist/modules/channel_discord/lib/interactions-dispatch.js +164 -0
- package/dist/modules/channel_discord/lib/interactions-dispatch.js.map +7 -0
- package/dist/modules/channel_discord/lib/interactions-handler.js +115 -0
- package/dist/modules/channel_discord/lib/interactions-handler.js.map +7 -0
- package/dist/modules/channel_discord/lib/interactions-queue.js +17 -0
- package/dist/modules/channel_discord/lib/interactions-queue.js.map +7 -0
- package/dist/modules/channel_discord/lib/interactions-verify.js +84 -0
- package/dist/modules/channel_discord/lib/interactions-verify.js.map +7 -0
- package/dist/modules/channel_discord/lib/normalize-inbound.js +59 -0
- package/dist/modules/channel_discord/lib/normalize-inbound.js.map +7 -0
- package/dist/modules/channel_discord/lib/preset.js +63 -0
- package/dist/modules/channel_discord/lib/preset.js.map +7 -0
- package/dist/modules/channel_discord/lib/queue-strategy.js +12 -0
- package/dist/modules/channel_discord/lib/queue-strategy.js.map +7 -0
- package/dist/modules/channel_discord/lib/slash-commands.js +42 -0
- package/dist/modules/channel_discord/lib/slash-commands.js.map +7 -0
- package/dist/modules/channel_discord/message-types.js +64 -0
- package/dist/modules/channel_discord/message-types.js.map +7 -0
- package/dist/modules/channel_discord/setup.js +62 -0
- package/dist/modules/channel_discord/setup.js.map +7 -0
- package/dist/modules/channel_discord/subscribers/ai-auto-reply.js +185 -0
- package/dist/modules/channel_discord/subscribers/ai-auto-reply.js.map +7 -0
- package/dist/modules/channel_discord/widgets/injection/ai-auto-reply/widget.client.js +79 -0
- package/dist/modules/channel_discord/widgets/injection/ai-auto-reply/widget.client.js.map +7 -0
- package/dist/modules/channel_discord/widgets/injection/ai-auto-reply/widget.js +20 -0
- package/dist/modules/channel_discord/widgets/injection/ai-auto-reply/widget.js.map +7 -0
- package/dist/modules/channel_discord/widgets/injection/connect/widget.client.js +220 -0
- package/dist/modules/channel_discord/widgets/injection/connect/widget.client.js.map +7 -0
- package/dist/modules/channel_discord/widgets/injection/connect/widget.js +17 -0
- package/dist/modules/channel_discord/widgets/injection/connect/widget.js.map +7 -0
- package/dist/modules/channel_discord/widgets/injection-table.js +26 -0
- package/dist/modules/channel_discord/widgets/injection-table.js.map +7 -0
- package/dist/modules/channel_discord/workers/discord-gateway.js +270 -0
- package/dist/modules/channel_discord/workers/discord-gateway.js.map +7 -0
- package/dist/modules/channel_discord/workers/discord-interactions.js +113 -0
- package/dist/modules/channel_discord/workers/discord-interactions.js.map +7 -0
- package/jest.config.cjs +38 -0
- package/package.json +104 -0
- package/src/index.ts +1 -0
- package/src/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-001.spec.ts +109 -0
- package/src/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-002.spec.ts +58 -0
- package/src/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-003.spec.ts +175 -0
- package/src/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-004.spec.ts +83 -0
- package/src/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-005.spec.ts +156 -0
- package/src/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-006.spec.ts +144 -0
- package/src/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-007.spec.ts +119 -0
- package/src/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-008.spec.ts +75 -0
- package/src/modules/channel_discord/__integration__/helpers/discordSignature.ts +77 -0
- package/src/modules/channel_discord/__integration__/helpers/eventsQueue.ts +26 -0
- package/src/modules/channel_discord/__integration__/meta.ts +6 -0
- package/src/modules/channel_discord/__tests__/ai-auto-reply.policy.integration.test.ts +286 -0
- package/src/modules/channel_discord/acl.ts +19 -0
- package/src/modules/channel_discord/ai-agents.ts +157 -0
- package/src/modules/channel_discord/api/ai-auto-reply/channels/__tests__/route.test.ts +175 -0
- package/src/modules/channel_discord/api/ai-auto-reply/channels/route.ts +99 -0
- package/src/modules/channel_discord/api/channels/[id]/ai-auto-reply/__tests__/route.test.ts +311 -0
- package/src/modules/channel_discord/api/channels/[id]/ai-auto-reply/route.ts +299 -0
- package/src/modules/channel_discord/api/interactions/route.ts +242 -0
- package/src/modules/channel_discord/backend/channel_discord/channels/[id]/ai-auto-reply/page.meta.ts +18 -0
- package/src/modules/channel_discord/backend/channel_discord/channels/[id]/ai-auto-reply/page.tsx +288 -0
- package/src/modules/channel_discord/cli.ts +234 -0
- package/src/modules/channel_discord/commands/__tests__/ai-reply-proposal.test.ts +155 -0
- package/src/modules/channel_discord/commands/__tests__/update-ai-auto-reply.test.ts +185 -0
- package/src/modules/channel_discord/commands/ai-reply-proposal.ts +162 -0
- package/src/modules/channel_discord/commands/update-ai-auto-reply.ts +157 -0
- package/src/modules/channel_discord/di.ts +26 -0
- package/src/modules/channel_discord/i18n/de.json +57 -0
- package/src/modules/channel_discord/i18n/en.json +57 -0
- package/src/modules/channel_discord/i18n/es.json +57 -0
- package/src/modules/channel_discord/i18n/ko.json +57 -0
- package/src/modules/channel_discord/i18n/pl.json +57 -0
- package/src/modules/channel_discord/index.ts +6 -0
- package/src/modules/channel_discord/integration.ts +98 -0
- package/src/modules/channel_discord/lib/__tests__/adapter.test.ts +206 -0
- package/src/modules/channel_discord/lib/__tests__/ai-agent-directory.test.ts +61 -0
- package/src/modules/channel_discord/lib/__tests__/ai-reply.test.ts +115 -0
- package/src/modules/channel_discord/lib/__tests__/capabilities.test.ts +170 -0
- package/src/modules/channel_discord/lib/__tests__/channel-quarantine.test.ts +89 -0
- package/src/modules/channel_discord/lib/__tests__/channel-state-store.auto-reply-marker.test.ts +145 -0
- package/src/modules/channel_discord/lib/__tests__/channel-state-store.test.ts +78 -0
- package/src/modules/channel_discord/lib/__tests__/convert-outbound.test.ts +39 -0
- package/src/modules/channel_discord/lib/__tests__/discord-gateway-client.test.ts +243 -0
- package/src/modules/channel_discord/lib/__tests__/failure-reason.test.ts +68 -0
- package/src/modules/channel_discord/lib/__tests__/gateway-bridge.test.ts +62 -0
- package/src/modules/channel_discord/lib/__tests__/interactions-dispatch.test.ts +223 -0
- package/src/modules/channel_discord/lib/__tests__/interactions-handler.test.ts +469 -0
- package/src/modules/channel_discord/lib/__tests__/interactions-verify.test.ts +126 -0
- package/src/modules/channel_discord/lib/__tests__/normalize-inbound.identity.test.ts +64 -0
- package/src/modules/channel_discord/lib/__tests__/normalize-inbound.test.ts +67 -0
- package/src/modules/channel_discord/lib/__tests__/preset.test.ts +110 -0
- package/src/modules/channel_discord/lib/__tests__/queue-strategy.test.ts +33 -0
- package/src/modules/channel_discord/lib/adapter.ts +359 -0
- package/src/modules/channel_discord/lib/ai-agent-directory.ts +124 -0
- package/src/modules/channel_discord/lib/ai-features.ts +23 -0
- package/src/modules/channel_discord/lib/ai-proposal-contract.ts +24 -0
- package/src/modules/channel_discord/lib/ai-proposal.ts +179 -0
- package/src/modules/channel_discord/lib/ai-reply.ts +179 -0
- package/src/modules/channel_discord/lib/ai-service-principal.ts +143 -0
- package/src/modules/channel_discord/lib/capabilities.ts +104 -0
- package/src/modules/channel_discord/lib/channel-access.ts +110 -0
- package/src/modules/channel_discord/lib/channel-state-store.ts +217 -0
- package/src/modules/channel_discord/lib/convert-outbound.ts +88 -0
- package/src/modules/channel_discord/lib/credentials.ts +88 -0
- package/src/modules/channel_discord/lib/discord-gateway-client.ts +432 -0
- package/src/modules/channel_discord/lib/discord-rest.ts +393 -0
- package/src/modules/channel_discord/lib/failure-reason.ts +49 -0
- package/src/modules/channel_discord/lib/gateway-bridge.ts +79 -0
- package/src/modules/channel_discord/lib/health.ts +55 -0
- package/src/modules/channel_discord/lib/interactions-dispatch.ts +288 -0
- package/src/modules/channel_discord/lib/interactions-handler.ts +267 -0
- package/src/modules/channel_discord/lib/interactions-queue.ts +52 -0
- package/src/modules/channel_discord/lib/interactions-verify.ts +174 -0
- package/src/modules/channel_discord/lib/normalize-inbound.ts +89 -0
- package/src/modules/channel_discord/lib/preset.ts +109 -0
- package/src/modules/channel_discord/lib/queue-strategy.ts +29 -0
- package/src/modules/channel_discord/lib/slash-commands.ts +57 -0
- package/src/modules/channel_discord/message-types.ts +75 -0
- package/src/modules/channel_discord/setup.ts +78 -0
- package/src/modules/channel_discord/subscribers/__tests__/ai-auto-reply.armed.test.ts +149 -0
- package/src/modules/channel_discord/subscribers/__tests__/ai-auto-reply.failure-marker.test.ts +133 -0
- package/src/modules/channel_discord/subscribers/__tests__/ai-auto-reply.test.ts +273 -0
- package/src/modules/channel_discord/subscribers/ai-auto-reply.ts +325 -0
- package/src/modules/channel_discord/widgets/injection/ai-auto-reply/widget.client.tsx +143 -0
- package/src/modules/channel_discord/widgets/injection/ai-auto-reply/widget.ts +19 -0
- package/src/modules/channel_discord/widgets/injection/connect/widget.client.tsx +237 -0
- package/src/modules/channel_discord/widgets/injection/connect/widget.ts +16 -0
- package/src/modules/channel_discord/widgets/injection-table.ts +24 -0
- package/src/modules/channel_discord/workers/__tests__/discord-gateway.single-identify.test.ts +88 -0
- package/src/modules/channel_discord/workers/__tests__/discord-gateway.test.ts +179 -0
- package/src/modules/channel_discord/workers/__tests__/discord-interactions.test.ts +152 -0
- package/src/modules/channel_discord/workers/discord-gateway.ts +458 -0
- package/src/modules/channel_discord/workers/discord-interactions.ts +162 -0
- package/tsconfig.json +9 -0
- package/watch.mjs +7 -0
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../../src/modules/channel_discord/lib/ai-agent-directory.ts"],
|
|
4
|
+
"sourcesContent": ["import { authorizeFeatures } from '@open-mercato/shared/security/featurePolicy'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\n\nconst logger = createLogger('channel_discord').child({ component: 'ai-agent-directory' })\n\n/** The subset of an agent definition the settings surface needs to describe a choice. */\nexport type DiscordEligibleAgent = {\n id: string\n label: string\n description: string\n requiredFeatures: string[]\n}\n\nexport type DiscordAgentDirectory =\n | { available: false }\n | { available: true; agents: DiscordEligibleAgent[] }\n\ntype AgentRegistryModule = {\n loadAgentRegistry: () => Promise<unknown>\n listAgents: () => Array<{\n id: string\n label?: string\n description?: string\n executionMode?: 'chat' | 'object'\n output?: unknown\n requiredFeatures?: string[]\n }>\n}\n\n/**\n * List the agents a Discord channel may legitimately be pointed at.\n *\n * \"Eligible\" means the AI runtime would accept the call the subscriber makes:\n * `runAiAgentObject` requests execution mode `object`, and `checkAgentPolicy`\n * rejects an agent that is neither declared object-mode nor carrying an output\n * schema (`execution_mode_not_supported`). Offering a chat-mode agent in the\n * picker would therefore be offering a setting that can only ever fail at\n * runtime \u2014 the exact failure mode this feature was filed to remove.\n *\n * The import is dynamic and failure-tolerant because `@open-mercato/ai-assistant`\n * is an optional peer: a deployment without it gets `{ available: false }`, and\n * the settings surface then explains why auto-reply cannot be enabled instead of\n * rendering an empty dropdown.\n */\nexport async function listDiscordEligibleAgents(): Promise<DiscordAgentDirectory> {\n let mod: AgentRegistryModule\n try {\n mod = (await import('@open-mercato/ai-assistant')) as unknown as AgentRegistryModule\n } catch {\n return { available: false }\n }\n if (typeof mod.loadAgentRegistry !== 'function' || typeof mod.listAgents !== 'function') {\n return { available: false }\n }\n\n try {\n await mod.loadAgentRegistry()\n } catch (err) {\n logger.warn('Failed to load the AI agent registry', { err })\n return { available: false }\n }\n\n const agents = mod\n .listAgents()\n .filter((agent) => agent.executionMode === 'object' || Boolean(agent.output))\n .map((agent) => ({\n id: agent.id,\n label: agent.label ?? agent.id,\n description: agent.description ?? '',\n requiredFeatures: agent.requiredFeatures ?? [],\n }))\n\n return { available: true, agents }\n}\n\n/**\n * The eligible agent `agentId` names, or `null` when the AI peer is absent or the\n * agent is not one auto-reply may be pointed at.\n *\n * This replaced a boolean `isDiscordEligibleAgentId`: every caller that wanted the\n * answer also wanted the agent's `requiredFeatures` for the authorization check\n * below, and asking twice meant loading the agent registry twice.\n */\nexport async function findDiscordEligibleAgent(agentId: string): Promise<DiscordEligibleAgent | null> {\n const directory = await listDiscordEligibleAgents()\n if (!directory.available) return null\n return directory.agents.find((agent) => agent.id === agentId) ?? null\n}\n\n/** The identity half of the invocability question \u2014 see {@link missingAgentFeatures}. */\nexport type AgentInvokingPrincipal = {\n features: string[]\n isSuperAdmin: boolean\n}\n\n/**\n * The subset of an agent's `requiredFeatures` the principal does NOT hold.\n *\n * This is the second half of \"can auto-reply actually invoke this agent\", and it\n * is the half the settings surface used to skip. `listDiscordEligibleAgents`\n * answers the *shape* question (object-mode, so `runAiAgentObject` will take it);\n * this answers the *authorization* question the runtime asks next, when\n * `checkAgentPolicy` runs the agent's `requiredFeatures` against the principal\n * from `lib/ai-service-principal.ts`. An agent that clears the first and fails the\n * second stores a setting that can only fail later, inside a background\n * subscriber \u2014 the failure mode issue #4778 was filed for.\n *\n * The check delegates to the platform's own `authorizeFeatures`, one feature at a\n * time so the caller can name what is missing. Going through the shared helper \u2014\n * rather than a `Set.has` \u2014 is what makes wildcard grants (`customers.*`, `*`),\n * removed features and disabled modules resolve exactly the way the runtime will\n * resolve them; a hand-rolled comparison would reject a bot user whose role\n * carries a wildcard and the operator would have no way to tell why.\n */\nexport function missingAgentFeatures(\n requiredFeatures: readonly string[] | undefined,\n principal: AgentInvokingPrincipal,\n): string[] {\n if (!requiredFeatures?.length) return []\n return requiredFeatures.filter((featureId) => !authorizeFeatures([featureId], {\n grantedFeatures: principal.features,\n unrestricted: principal.isSuperAdmin,\n }))\n}\n"],
|
|
5
|
+
"mappings": "AAAA,SAAS,yBAAyB;AAClC,SAAS,oBAAoB;AAE7B,MAAM,SAAS,aAAa,iBAAiB,EAAE,MAAM,EAAE,WAAW,qBAAqB,CAAC;AAyCxF,eAAsB,4BAA4D;AAChF,MAAI;AACJ,MAAI;AACF,UAAO,MAAM,OAAO,4BAA4B;AAAA,EAClD,QAAQ;AACN,WAAO,EAAE,WAAW,MAAM;AAAA,EAC5B;AACA,MAAI,OAAO,IAAI,sBAAsB,cAAc,OAAO,IAAI,eAAe,YAAY;AACvF,WAAO,EAAE,WAAW,MAAM;AAAA,EAC5B;AAEA,MAAI;AACF,UAAM,IAAI,kBAAkB;AAAA,EAC9B,SAAS,KAAK;AACZ,WAAO,KAAK,wCAAwC,EAAE,IAAI,CAAC;AAC3D,WAAO,EAAE,WAAW,MAAM;AAAA,EAC5B;AAEA,QAAM,SAAS,IACZ,WAAW,EACX,OAAO,CAAC,UAAU,MAAM,kBAAkB,YAAY,QAAQ,MAAM,MAAM,CAAC,EAC3E,IAAI,CAAC,WAAW;AAAA,IACf,IAAI,MAAM;AAAA,IACV,OAAO,MAAM,SAAS,MAAM;AAAA,IAC5B,aAAa,MAAM,eAAe;AAAA,IAClC,kBAAkB,MAAM,oBAAoB,CAAC;AAAA,EAC/C,EAAE;AAEJ,SAAO,EAAE,WAAW,MAAM,OAAO;AACnC;AAUA,eAAsB,yBAAyB,SAAuD;AACpG,QAAM,YAAY,MAAM,0BAA0B;AAClD,MAAI,CAAC,UAAU,UAAW,QAAO;AACjC,SAAO,UAAU,OAAO,KAAK,CAAC,UAAU,MAAM,OAAO,OAAO,KAAK;AACnE;AA2BO,SAAS,qBACd,kBACA,WACU;AACV,MAAI,CAAC,kBAAkB,OAAQ,QAAO,CAAC;AACvC,SAAO,iBAAiB,OAAO,CAAC,cAAc,CAAC,kBAAkB,CAAC,SAAS,GAAG;AAAA,IAC5E,iBAAiB,UAAU;AAAA,IAC3B,cAAc,UAAU;AAAA,EAC1B,CAAC,CAAC;AACJ;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
const CHANNEL_DISCORD_AI_AUTO_REPLY_RUN_FEATURE = "channel_discord.ai_auto_reply.run";
|
|
2
|
+
const CHANNEL_DISCORD_CONFIGURE_FEATURE = "channel_discord.configure";
|
|
3
|
+
const CHANNEL_DISCORD_VIEW_FEATURE = "channel_discord.view";
|
|
4
|
+
export {
|
|
5
|
+
CHANNEL_DISCORD_AI_AUTO_REPLY_RUN_FEATURE,
|
|
6
|
+
CHANNEL_DISCORD_CONFIGURE_FEATURE,
|
|
7
|
+
CHANNEL_DISCORD_VIEW_FEATURE
|
|
8
|
+
};
|
|
9
|
+
//# sourceMappingURL=ai-features.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../../src/modules/channel_discord/lib/ai-features.ts"],
|
|
4
|
+
"sourcesContent": ["/**\n * ACL feature ids the AI auto-reply path depends on, in one dependency-free\n * module so `acl.ts`, `ai-agents.ts`, the API routes, and the service principal\n * can all import the same literal instead of repeating the string. Feature ids\n * are FROZEN once shipped (`BACKWARD_COMPATIBILITY.md` \u00A7 ACL features) \u2014 add new\n * ones rather than renaming these.\n */\n\n/**\n * Gates invoking the Discord auto-reply agent. It grants no data access of its\n * own: the agent it unlocks runs in object mode with no tools, so the only thing\n * this feature buys its holder is the ability to have text drafted.\n *\n * It is also the single grant the subscriber's service principal falls back to\n * when a tenant has no channel-bot user \u2014 see `lib/ai-service-principal.ts`.\n */\nexport const CHANNEL_DISCORD_AI_AUTO_REPLY_RUN_FEATURE = 'channel_discord.ai_auto_reply.run'\n\n/** Gates reading and writing a channel's AI auto-reply configuration. */\nexport const CHANNEL_DISCORD_CONFIGURE_FEATURE = 'channel_discord.configure'\n\n/** Gates read-only access to the Discord channel configuration surfaces. */\nexport const CHANNEL_DISCORD_VIEW_FEATURE = 'channel_discord.view'\n"],
|
|
5
|
+
"mappings": "AAgBO,MAAM,4CAA4C;AAGlD,MAAM,oCAAoC;AAG1C,MAAM,+BAA+B;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
const CHANNEL_DISCORD_AI_PROPOSAL_APPROVE_COMMAND_ID = "channel_discord.ai_reply_proposal.approve";
|
|
2
|
+
const CHANNEL_DISCORD_AI_PROPOSAL_DISMISS_COMMAND_ID = "channel_discord.ai_reply_proposal.dismiss";
|
|
3
|
+
const CHANNEL_DISCORD_AI_PROPOSAL_SOURCE_ENTITY_TYPE = "messages.message";
|
|
4
|
+
export {
|
|
5
|
+
CHANNEL_DISCORD_AI_PROPOSAL_APPROVE_COMMAND_ID,
|
|
6
|
+
CHANNEL_DISCORD_AI_PROPOSAL_DISMISS_COMMAND_ID,
|
|
7
|
+
CHANNEL_DISCORD_AI_PROPOSAL_SOURCE_ENTITY_TYPE
|
|
8
|
+
};
|
|
9
|
+
//# sourceMappingURL=ai-proposal-contract.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../../src/modules/channel_discord/lib/ai-proposal-contract.ts"],
|
|
4
|
+
"sourcesContent": ["/**\n * Ids shared between the AI proposal's message type, its commands, and the\n * subscriber that files it. They live in their own dependency-free module so\n * `message-types.ts` (loaded by the messages registry) and `commands/*.ts`\n * (loaded by the command bus) can agree on the strings without importing each\n * other and creating a cycle.\n *\n * Command ids are a contract surface (`BACKWARD_COMPATIBILITY.md`): renaming one\n * breaks every proposal message already sitting in an operator's inbox, because\n * the action row stored on that message still names the old id.\n */\nexport const CHANNEL_DISCORD_AI_PROPOSAL_APPROVE_COMMAND_ID =\n 'channel_discord.ai_reply_proposal.approve'\n\nexport const CHANNEL_DISCORD_AI_PROPOSAL_DISMISS_COMMAND_ID =\n 'channel_discord.ai_reply_proposal.dismiss'\n\n/**\n * `sourceEntityType` on the proposal message. The approve command reads\n * `sourceEntityId` as the id of the inbound Discord message being answered, so\n * it can resolve the thread to reply into without trusting anything the operator\n * sends from the browser.\n */\nexport const CHANNEL_DISCORD_AI_PROPOSAL_SOURCE_ENTITY_TYPE = 'messages.message'\n"],
|
|
5
|
+
"mappings": "AAWO,MAAM,iDACX;AAEK,MAAM,iDACX;AAQK,MAAM,iDAAiD;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { createLogger } from "@open-mercato/shared/lib/logger";
|
|
2
|
+
import { findOneWithDecryption } from "@open-mercato/shared/lib/encryption/find";
|
|
3
|
+
import {
|
|
4
|
+
ChannelThreadMapping,
|
|
5
|
+
CommunicationChannel,
|
|
6
|
+
ExternalConversation
|
|
7
|
+
} from "@open-mercato/core/modules/communication_channels/data/entities";
|
|
8
|
+
import { CHANNEL_DISCORD_AI_PROPOSAL_SOURCE_ENTITY_TYPE } from "./ai-proposal-contract.js";
|
|
9
|
+
import { CHANNEL_DISCORD_AI_PROPOSAL_MESSAGE_TYPE } from "../message-types.js";
|
|
10
|
+
const logger = createLogger("channel_discord").child({ component: "ai-proposal" });
|
|
11
|
+
const MAX_SUBJECT_LENGTH = 200;
|
|
12
|
+
const MAX_SUMMARY_LENGTH = 500;
|
|
13
|
+
async function resolveDiscordProposalReviewer(params) {
|
|
14
|
+
const { em, scope, channelId, messageThreadId, externalConversationId } = params;
|
|
15
|
+
if (messageThreadId) {
|
|
16
|
+
const mapping = await em.findOne(ChannelThreadMapping, {
|
|
17
|
+
messageThreadId,
|
|
18
|
+
tenantId: scope.tenantId
|
|
19
|
+
});
|
|
20
|
+
if (mapping?.assignedUserId) return mapping.assignedUserId;
|
|
21
|
+
}
|
|
22
|
+
if (externalConversationId) {
|
|
23
|
+
const conversation = await em.findOne(ExternalConversation, {
|
|
24
|
+
id: externalConversationId,
|
|
25
|
+
tenantId: scope.tenantId
|
|
26
|
+
});
|
|
27
|
+
if (conversation?.assignedUserId) return conversation.assignedUserId;
|
|
28
|
+
}
|
|
29
|
+
const channel = await findOneWithDecryption(
|
|
30
|
+
em,
|
|
31
|
+
CommunicationChannel,
|
|
32
|
+
{ id: channelId, tenantId: scope.tenantId, deletedAt: null },
|
|
33
|
+
void 0,
|
|
34
|
+
{ tenantId: scope.tenantId, organizationId: scope.organizationId ?? scope.tenantId }
|
|
35
|
+
);
|
|
36
|
+
return channel?.userId ?? null;
|
|
37
|
+
}
|
|
38
|
+
async function fileDiscordReplyProposal(params) {
|
|
39
|
+
const {
|
|
40
|
+
em,
|
|
41
|
+
commandBus,
|
|
42
|
+
containerCtx,
|
|
43
|
+
scope,
|
|
44
|
+
channelId,
|
|
45
|
+
inboundMessage,
|
|
46
|
+
externalConversationId,
|
|
47
|
+
botUserId,
|
|
48
|
+
proposedReply,
|
|
49
|
+
summary,
|
|
50
|
+
reason
|
|
51
|
+
} = params;
|
|
52
|
+
const reviewerUserId = await resolveDiscordProposalReviewer({
|
|
53
|
+
em,
|
|
54
|
+
scope,
|
|
55
|
+
channelId,
|
|
56
|
+
messageThreadId: inboundMessage.threadId ?? inboundMessage.id,
|
|
57
|
+
externalConversationId
|
|
58
|
+
});
|
|
59
|
+
const subject = summary.trim().slice(0, MAX_SUBJECT_LENGTH) || "Proposed Discord reply";
|
|
60
|
+
const composeInput = {
|
|
61
|
+
type: CHANNEL_DISCORD_AI_PROPOSAL_MESSAGE_TYPE,
|
|
62
|
+
visibility: "internal",
|
|
63
|
+
sourceEntityType: CHANNEL_DISCORD_AI_PROPOSAL_SOURCE_ENTITY_TYPE,
|
|
64
|
+
sourceEntityId: inboundMessage.id,
|
|
65
|
+
recipients: reviewerUserId ? [{ userId: reviewerUserId, type: "to" }] : [],
|
|
66
|
+
subject,
|
|
67
|
+
body: proposedReply,
|
|
68
|
+
bodyFormat: "markdown",
|
|
69
|
+
priority: "normal",
|
|
70
|
+
sendViaEmail: false,
|
|
71
|
+
// A draft when there is nobody to address it to — the compose validator only
|
|
72
|
+
// requires a recipient for a message that is actually being delivered.
|
|
73
|
+
isDraft: !reviewerUserId,
|
|
74
|
+
tenantId: scope.tenantId,
|
|
75
|
+
organizationId: scope.organizationId,
|
|
76
|
+
userId: botUserId
|
|
77
|
+
};
|
|
78
|
+
const composeResult = await commandBus.execute(
|
|
79
|
+
"messages.messages.compose",
|
|
80
|
+
{
|
|
81
|
+
input: composeInput,
|
|
82
|
+
ctx: containerCtx
|
|
83
|
+
}
|
|
84
|
+
);
|
|
85
|
+
const outcome = reviewerUserId ? "assigned" : "stored";
|
|
86
|
+
if (outcome === "stored") {
|
|
87
|
+
logger.info(
|
|
88
|
+
"Filed a Discord AI reply proposal with no reviewer \u2014 assign the conversation to get approval cards",
|
|
89
|
+
{ channelId, reason, proposalMessageId: composeResult.result.id }
|
|
90
|
+
);
|
|
91
|
+
} else {
|
|
92
|
+
logger.info("Filed a Discord AI reply proposal for review", {
|
|
93
|
+
channelId,
|
|
94
|
+
reason,
|
|
95
|
+
proposalMessageId: composeResult.result.id
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
return {
|
|
99
|
+
proposalMessageId: composeResult.result.id,
|
|
100
|
+
outcome,
|
|
101
|
+
reviewerUserId
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
export {
|
|
105
|
+
MAX_SUMMARY_LENGTH,
|
|
106
|
+
fileDiscordReplyProposal,
|
|
107
|
+
resolveDiscordProposalReviewer
|
|
108
|
+
};
|
|
109
|
+
//# sourceMappingURL=ai-proposal.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../../src/modules/channel_discord/lib/ai-proposal.ts"],
|
|
4
|
+
"sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\nimport type { CommandBus } from '@open-mercato/shared/lib/commands'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\nimport { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport {\n ChannelThreadMapping,\n CommunicationChannel,\n ExternalConversation,\n} from '@open-mercato/core/modules/communication_channels/data/entities'\nimport type { Message } from '@open-mercato/core/modules/messages/data/entities'\nimport { CHANNEL_DISCORD_AI_PROPOSAL_SOURCE_ENTITY_TYPE } from './ai-proposal-contract'\nimport { CHANNEL_DISCORD_AI_PROPOSAL_MESSAGE_TYPE } from '../message-types'\n\nconst logger = createLogger('channel_discord').child({ component: 'ai-proposal' })\n\nexport type DiscordProposalScope = { tenantId: string; organizationId: string | null }\n\nexport type FileDiscordProposalResult = {\n proposalMessageId: string\n /** `'assigned'` went to a human with approve/dismiss actions; `'stored'` is a draft nobody was notified about. */\n outcome: 'assigned' | 'stored'\n reviewerUserId: string | null\n}\n\nconst MAX_SUBJECT_LENGTH = 200\nconst MAX_SUMMARY_LENGTH = 500\n\n/**\n * Find the human who should review a proposal for this conversation.\n *\n * Order mirrors what the hub itself does when it decides who an INBOUND channel\n * message is addressed to (`ingest-inbound-message.ts` uses the thread mapping's\n * assignee), then widens to the two other owners a Discord channel can have. It\n * deliberately does not fan out to \"everyone with the feature\": a proposal is a\n * task for the person who owns the conversation, not a broadcast.\n */\nexport async function resolveDiscordProposalReviewer(params: {\n em: EntityManager\n scope: DiscordProposalScope\n channelId: string\n messageThreadId: string | null\n externalConversationId: string | null\n}): Promise<string | null> {\n const { em, scope, channelId, messageThreadId, externalConversationId } = params\n\n if (messageThreadId) {\n const mapping = await em.findOne(ChannelThreadMapping, {\n messageThreadId,\n tenantId: scope.tenantId,\n })\n if (mapping?.assignedUserId) return mapping.assignedUserId\n }\n\n if (externalConversationId) {\n const conversation = await em.findOne(ExternalConversation, {\n id: externalConversationId,\n tenantId: scope.tenantId,\n })\n if (conversation?.assignedUserId) return conversation.assignedUserId\n }\n\n const channel = await findOneWithDecryption(\n em,\n CommunicationChannel,\n { id: channelId, tenantId: scope.tenantId, deletedAt: null },\n undefined,\n { tenantId: scope.tenantId, organizationId: scope.organizationId ?? scope.tenantId },\n )\n return channel?.userId ?? null\n}\n\n/**\n * File the `complex`-tier proposal (SPEC 2026-06-19 \u00A7 AI bot wiring step 5,\n * issue #4778). This is what replaced \"log a line and return\".\n *\n * Two shapes, one helper:\n *\n * - A reviewer resolves \u2192 an INTERNAL message addressed to them, carrying the\n * drafted reply as its body and the approve/dismiss actions declared by\n * `message-types.ts`. Approving dispatches\n * `channel_discord.ai_reply_proposal.approve`, which composes the public reply\n * through the same hub path the easy tier uses.\n * - No reviewer resolves (an unassigned tenant-scoped channel \u2014 the same case\n * where the hub files the inbound message with no recipient either) \u2192 the\n * proposal is stored as a DRAFT so the text is not lost, and the fact that\n * nobody was notified is logged. Assigning the conversation is the operator\n * action that turns future proposals into approval cards.\n *\n * Neither shape can reach Discord on its own: a draft emits no\n * `messages.message.sent`, and the internal message is `visibility: 'internal'`,\n * which the hub's outbound bridge does not deliver. Only the approve command\n * sends, and only a human can call it.\n */\nexport async function fileDiscordReplyProposal(params: {\n em: EntityManager\n commandBus: CommandBus\n containerCtx: unknown\n scope: DiscordProposalScope\n channelId: string\n inboundMessage: Pick<Message, 'id' | 'type' | 'subject' | 'threadId'>\n externalConversationId: string | null\n botUserId: string\n proposedReply: string\n summary: string\n reason: string\n}): Promise<FileDiscordProposalResult> {\n const {\n em,\n commandBus,\n containerCtx,\n scope,\n channelId,\n inboundMessage,\n externalConversationId,\n botUserId,\n proposedReply,\n summary,\n reason,\n } = params\n\n const reviewerUserId = await resolveDiscordProposalReviewer({\n em,\n scope,\n channelId,\n messageThreadId: inboundMessage.threadId ?? inboundMessage.id,\n externalConversationId,\n })\n\n const subject = summary.trim().slice(0, MAX_SUBJECT_LENGTH) || 'Proposed Discord reply'\n\n const composeInput = {\n type: CHANNEL_DISCORD_AI_PROPOSAL_MESSAGE_TYPE,\n visibility: 'internal' as const,\n sourceEntityType: CHANNEL_DISCORD_AI_PROPOSAL_SOURCE_ENTITY_TYPE,\n sourceEntityId: inboundMessage.id,\n recipients: reviewerUserId ? [{ userId: reviewerUserId, type: 'to' as const }] : [],\n subject,\n body: proposedReply,\n bodyFormat: 'markdown' as const,\n priority: 'normal' as const,\n sendViaEmail: false,\n // A draft when there is nobody to address it to \u2014 the compose validator only\n // requires a recipient for a message that is actually being delivered.\n isDraft: !reviewerUserId,\n tenantId: scope.tenantId,\n organizationId: scope.organizationId,\n userId: botUserId,\n }\n\n const composeResult = await commandBus.execute<typeof composeInput, { id: string }>(\n 'messages.messages.compose',\n {\n input: composeInput,\n ctx: containerCtx as never,\n },\n )\n\n const outcome: FileDiscordProposalResult['outcome'] = reviewerUserId ? 'assigned' : 'stored'\n if (outcome === 'stored') {\n logger.info(\n 'Filed a Discord AI reply proposal with no reviewer \u2014 assign the conversation to get approval cards',\n { channelId, reason, proposalMessageId: composeResult.result.id },\n )\n } else {\n logger.info('Filed a Discord AI reply proposal for review', {\n channelId,\n reason,\n proposalMessageId: composeResult.result.id,\n })\n }\n\n return {\n proposalMessageId: composeResult.result.id,\n outcome,\n reviewerUserId,\n }\n}\n\nexport { MAX_SUMMARY_LENGTH }\n"],
|
|
5
|
+
"mappings": "AAEA,SAAS,oBAAoB;AAC7B,SAAS,6BAA6B;AACtC;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,OACK;AAEP,SAAS,sDAAsD;AAC/D,SAAS,gDAAgD;AAEzD,MAAM,SAAS,aAAa,iBAAiB,EAAE,MAAM,EAAE,WAAW,cAAc,CAAC;AAWjF,MAAM,qBAAqB;AAC3B,MAAM,qBAAqB;AAW3B,eAAsB,+BAA+B,QAM1B;AACzB,QAAM,EAAE,IAAI,OAAO,WAAW,iBAAiB,uBAAuB,IAAI;AAE1E,MAAI,iBAAiB;AACnB,UAAM,UAAU,MAAM,GAAG,QAAQ,sBAAsB;AAAA,MACrD;AAAA,MACA,UAAU,MAAM;AAAA,IAClB,CAAC;AACD,QAAI,SAAS,eAAgB,QAAO,QAAQ;AAAA,EAC9C;AAEA,MAAI,wBAAwB;AAC1B,UAAM,eAAe,MAAM,GAAG,QAAQ,sBAAsB;AAAA,MAC1D,IAAI;AAAA,MACJ,UAAU,MAAM;AAAA,IAClB,CAAC;AACD,QAAI,cAAc,eAAgB,QAAO,aAAa;AAAA,EACxD;AAEA,QAAM,UAAU,MAAM;AAAA,IACpB;AAAA,IACA;AAAA,IACA,EAAE,IAAI,WAAW,UAAU,MAAM,UAAU,WAAW,KAAK;AAAA,IAC3D;AAAA,IACA,EAAE,UAAU,MAAM,UAAU,gBAAgB,MAAM,kBAAkB,MAAM,SAAS;AAAA,EACrF;AACA,SAAO,SAAS,UAAU;AAC5B;AAwBA,eAAsB,yBAAyB,QAYR;AACrC,QAAM;AAAA,IACJ;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,EACF,IAAI;AAEJ,QAAM,iBAAiB,MAAM,+BAA+B;AAAA,IAC1D;AAAA,IACA;AAAA,IACA;AAAA,IACA,iBAAiB,eAAe,YAAY,eAAe;AAAA,IAC3D;AAAA,EACF,CAAC;AAED,QAAM,UAAU,QAAQ,KAAK,EAAE,MAAM,GAAG,kBAAkB,KAAK;AAE/D,QAAM,eAAe;AAAA,IACnB,MAAM;AAAA,IACN,YAAY;AAAA,IACZ,kBAAkB;AAAA,IAClB,gBAAgB,eAAe;AAAA,IAC/B,YAAY,iBAAiB,CAAC,EAAE,QAAQ,gBAAgB,MAAM,KAAc,CAAC,IAAI,CAAC;AAAA,IAClF;AAAA,IACA,MAAM;AAAA,IACN,YAAY;AAAA,IACZ,UAAU;AAAA,IACV,cAAc;AAAA;AAAA;AAAA,IAGd,SAAS,CAAC;AAAA,IACV,UAAU,MAAM;AAAA,IAChB,gBAAgB,MAAM;AAAA,IACtB,QAAQ;AAAA,EACV;AAEA,QAAM,gBAAgB,MAAM,WAAW;AAAA,IACrC;AAAA,IACA;AAAA,MACE,OAAO;AAAA,MACP,KAAK;AAAA,IACP;AAAA,EACF;AAEA,QAAM,UAAgD,iBAAiB,aAAa;AACpF,MAAI,YAAY,UAAU;AACxB,WAAO;AAAA,MACL;AAAA,MACA,EAAE,WAAW,QAAQ,mBAAmB,cAAc,OAAO,GAAG;AAAA,IAClE;AAAA,EACF,OAAO;AACL,WAAO,KAAK,gDAAgD;AAAA,MAC1D;AAAA,MACA;AAAA,MACA,mBAAmB,cAAc,OAAO;AAAA,IAC1C,CAAC;AAAA,EACH;AAEA,SAAO;AAAA,IACL,mBAAmB,cAAc,OAAO;AAAA,IACxC;AAAA,IACA;AAAA,EACF;AACF;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { discordChannelStateSchema } from "./credentials.js";
|
|
2
|
+
const COMPLEX_SIGNALS = [
|
|
3
|
+
/\brefund\b/i,
|
|
4
|
+
/\bcancel\b/i,
|
|
5
|
+
/\binvoice\b/i,
|
|
6
|
+
/\bchargeback\b/i,
|
|
7
|
+
/\bcomplain/i,
|
|
8
|
+
/\bescalat/i,
|
|
9
|
+
/\blegal\b/i,
|
|
10
|
+
/\bhuman\b/i,
|
|
11
|
+
/\bagent\b/i,
|
|
12
|
+
/\bmanager\b/i,
|
|
13
|
+
/\border\b/i,
|
|
14
|
+
/\bpayment\b/i,
|
|
15
|
+
/\bprice\b/i,
|
|
16
|
+
/\bdiscount\b/i
|
|
17
|
+
];
|
|
18
|
+
const INJECTION_SIGNALS = [
|
|
19
|
+
/\bignore\b[\s\S]{0,60}\b(instructions?|prompts?|rules?)\b/i,
|
|
20
|
+
/\bdisregard\b/i,
|
|
21
|
+
/\bsystem\s*prompt\b/i,
|
|
22
|
+
/\bjailbreak/i,
|
|
23
|
+
/\bdeveloper\s+mode\b/i,
|
|
24
|
+
/\byou\s+are\s+now\b/i,
|
|
25
|
+
/\bnew\s+instructions?\b/i,
|
|
26
|
+
/\bpretend\s+(to\s+be|you)\b/i,
|
|
27
|
+
/\bact\s+as\b/i,
|
|
28
|
+
/\brole\s*-?\s*play\b/i,
|
|
29
|
+
/^\s*(system|assistant)\s*:/im
|
|
30
|
+
];
|
|
31
|
+
const LINK_SIGNAL = /\bhttps?:\/\/|\bwww\.|\bdiscord\.gg\//i;
|
|
32
|
+
const INVISIBLE_CHARS = /[\u200b-\u200f\u202a-\u202e\u2060-\u2064\ufeff]/;
|
|
33
|
+
function normalizeForClassification(text) {
|
|
34
|
+
return text.normalize("NFKC").replace(/[\u200b-\u200f\u202a-\u202e\u2060-\u2064\ufeff]/g, "").replace(/[`*_~|]/g, "").normalize("NFKD").replace(/[\u0300-\u036f]/g, "").replace(/[^\S\n]+/g, " ").trim();
|
|
35
|
+
}
|
|
36
|
+
function matchSignal(signals, raw, normalized) {
|
|
37
|
+
for (const signal of signals) {
|
|
38
|
+
if (signal.test(raw) || signal.test(normalized)) return signal;
|
|
39
|
+
}
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
function classifyDiscordMessage(body) {
|
|
43
|
+
const text = (body ?? "").trim();
|
|
44
|
+
if (text.length === 0) {
|
|
45
|
+
return { tier: "complex", confidence: 0, reason: "empty-body" };
|
|
46
|
+
}
|
|
47
|
+
if (INVISIBLE_CHARS.test(text)) {
|
|
48
|
+
return { tier: "complex", confidence: 0.95, reason: "obfuscation-suspect" };
|
|
49
|
+
}
|
|
50
|
+
const normalized = normalizeForClassification(text);
|
|
51
|
+
const injection = matchSignal(INJECTION_SIGNALS, text, normalized);
|
|
52
|
+
if (injection) {
|
|
53
|
+
return { tier: "complex", confidence: 0.95, reason: `injection-suspect:${injection.source}` };
|
|
54
|
+
}
|
|
55
|
+
if (LINK_SIGNAL.test(text) || LINK_SIGNAL.test(normalized)) {
|
|
56
|
+
return { tier: "complex", confidence: 0.9, reason: "contains-link" };
|
|
57
|
+
}
|
|
58
|
+
const complexSignal = matchSignal(COMPLEX_SIGNALS, text, normalized);
|
|
59
|
+
if (complexSignal) {
|
|
60
|
+
return { tier: "complex", confidence: 0.9, reason: `matched:${complexSignal.source}` };
|
|
61
|
+
}
|
|
62
|
+
const questionCount = (text.match(/\?/g) ?? []).length;
|
|
63
|
+
if (text.length > 600 || questionCount > 2) {
|
|
64
|
+
return { tier: "complex", confidence: 0.6, reason: "long-or-multi-question" };
|
|
65
|
+
}
|
|
66
|
+
return { tier: "easy", confidence: 0.8, reason: "short-simple" };
|
|
67
|
+
}
|
|
68
|
+
function isAiAutoReplyEnabled(channelState) {
|
|
69
|
+
const parsed = discordChannelStateSchema.safeParse(channelState ?? {});
|
|
70
|
+
return parsed.success ? Boolean(parsed.data.aiAutoReplyEnabled) : false;
|
|
71
|
+
}
|
|
72
|
+
function resolveAiAgentId(channelState) {
|
|
73
|
+
const parsed = discordChannelStateSchema.safeParse(channelState ?? {});
|
|
74
|
+
return parsed.success ? parsed.data.aiAgentId : void 0;
|
|
75
|
+
}
|
|
76
|
+
function hasStoredAutoReplyFailure(channelState) {
|
|
77
|
+
const parsed = discordChannelStateSchema.safeParse(channelState ?? {});
|
|
78
|
+
return parsed.success ? parsed.data.aiAutoReplyLastError !== void 0 : false;
|
|
79
|
+
}
|
|
80
|
+
function isAiAssistantAvailable(ctx) {
|
|
81
|
+
const resolver = typeof ctx?.resolve === "function" ? ctx.resolve : ctx?.container?.resolve ? ctx.container.resolve.bind(ctx.container) : null;
|
|
82
|
+
if (!resolver) return false;
|
|
83
|
+
try {
|
|
84
|
+
return Boolean(resolver("mcpToolRegistry"));
|
|
85
|
+
} catch {
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
export {
|
|
90
|
+
classifyDiscordMessage,
|
|
91
|
+
hasStoredAutoReplyFailure,
|
|
92
|
+
isAiAssistantAvailable,
|
|
93
|
+
isAiAutoReplyEnabled,
|
|
94
|
+
resolveAiAgentId
|
|
95
|
+
};
|
|
96
|
+
//# sourceMappingURL=ai-reply.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../../src/modules/channel_discord/lib/ai-reply.ts"],
|
|
4
|
+
"sourcesContent": ["import { discordChannelStateSchema, type DiscordChannelState } from './credentials'\n\n/**\n * AI auto-reply helpers (SPEC 2026-06-19 \u00A7 AI bot wiring).\n *\n * `aiAutoReplyEnabled` / `aiAgentId` are written by the channel's AI auto-reply\n * settings form (`backend/channel_discord/channels/[id]/ai-auto-reply`) through\n * `PUT /api/channel_discord/channels/{id}/ai-auto-reply`. Both default to off, so\n * a channel nobody configured never reaches the model.\n *\n * `ai_assistant` is an OPTIONAL peer. This module never statically imports it;\n * the subscriber resolves it softly via DI (`mcpToolRegistry`) and, only when it\n * is present AND per-channel auto-reply is enabled, dynamically imports\n * `runAiAgentObject`. When the peer is absent the subscriber no-ops and the channel\n * still works as a plain inbox (module-decoupling contract).\n */\n\nexport type ReplyTier = 'easy' | 'complex'\n\nexport interface ClassificationResult {\n tier: ReplyTier\n confidence: number\n reason: string\n}\n\n// Signals that a message needs a human (mutations, escalation, sensitive topics).\n// The bot never auto-answers these \u2014 it proposes for approval instead (SPEC-056\n// easy-vs-complex tiering).\nconst COMPLEX_SIGNALS = [\n /\\brefund\\b/i,\n /\\bcancel\\b/i,\n /\\binvoice\\b/i,\n /\\bchargeback\\b/i,\n /\\bcomplain/i,\n /\\bescalat/i,\n /\\blegal\\b/i,\n /\\bhuman\\b/i,\n /\\bagent\\b/i,\n /\\bmanager\\b/i,\n /\\border\\b/i,\n /\\bpayment\\b/i,\n /\\bprice\\b/i,\n /\\bdiscount\\b/i,\n]\n\n// Signals that a message is trying to steer the model rather than ask a\n// question (prompt injection). Always propose-only \u2014 a human sees the attempt\n// before anything is sent.\nconst INJECTION_SIGNALS = [\n /\\bignore\\b[\\s\\S]{0,60}\\b(instructions?|prompts?|rules?)\\b/i,\n /\\bdisregard\\b/i,\n /\\bsystem\\s*prompt\\b/i,\n /\\bjailbreak/i,\n /\\bdeveloper\\s+mode\\b/i,\n /\\byou\\s+are\\s+now\\b/i,\n /\\bnew\\s+instructions?\\b/i,\n /\\bpretend\\s+(to\\s+be|you)\\b/i,\n /\\bact\\s+as\\b/i,\n /\\brole\\s*-?\\s*play\\b/i,\n /^\\s*(system|assistant)\\s*:/im,\n]\n\n// The bot must never act on content it would have to fetch \u2014 a link is a\n// second, unvetted instruction channel.\nconst LINK_SIGNAL = /\\bhttps?:\\/\\/|\\bwww\\.|\\bdiscord\\.gg\\//i\n\n// Zero-width, bidi-override, and word-joiner characters have no place in a\n// legitimate support question; their presence means someone is hiding content\n// from pattern matching (e.g. `re\u200Bfund`).\nconst INVISIBLE_CHARS = /[\\u200b-\\u200f\\u202a-\\u202e\\u2060-\\u2064\\ufeff]/\n\n/**\n * Build the copy of the text that signals are matched against. Defeats the\n * cheap obfuscations of a regex gate: compatibility forms (`\uFF52\uFF45\uFF46\uFF55\uFF4E\uFF44` \u2192\n * `refund`, NFKC), invisible characters, markdown emphasis/spoilers splitting a\n * keyword (`**ref**und`, `||refund||`), and combining-mark zalgo (NFKD + strip).\n */\nfunction normalizeForClassification(text: string): string {\n return text\n .normalize('NFKC')\n .replace(/[\\u200b-\\u200f\\u202a-\\u202e\\u2060-\\u2064\\ufeff]/g, '')\n .replace(/[`*_~|]/g, '')\n .normalize('NFKD')\n .replace(/[\\u0300-\\u036f]/g, '')\n .replace(/[^\\S\\n]+/g, ' ')\n .trim()\n}\n\nfunction matchSignal(signals: RegExp[], raw: string, normalized: string): RegExp | null {\n for (const signal of signals) {\n if (signal.test(raw) || signal.test(normalized)) return signal\n }\n return null\n}\n\n/**\n * Classify an inbound Discord message as \"easy\" (bot may answer directly) vs\n * \"complex\" (propose-only, human approves). A deliberately conservative\n * heuristic \u2014 when in doubt it returns `complex` so nothing risky auto-sends.\n *\n * Hardening note: signals are matched against a normalized copy as well as the\n * raw text, and injection/link/obfuscation attempts force `complex`. This is\n * defense-in-depth layering, not a proof \u2014 the real containment for anything\n * that slips through is downstream: propose-only for complex, an object-mode\n * agent the runtime never gives a tool to, a service principal holding one\n * non-data feature and never `isSuperAdmin`, `allowed_mentions: { parse: [] }`,\n * and the audited outbound path.\n */\nexport function classifyDiscordMessage(body: string): ClassificationResult {\n const text = (body ?? '').trim()\n if (text.length === 0) {\n return { tier: 'complex', confidence: 0, reason: 'empty-body' }\n }\n if (INVISIBLE_CHARS.test(text)) {\n return { tier: 'complex', confidence: 0.95, reason: 'obfuscation-suspect' }\n }\n const normalized = normalizeForClassification(text)\n const injection = matchSignal(INJECTION_SIGNALS, text, normalized)\n if (injection) {\n return { tier: 'complex', confidence: 0.95, reason: `injection-suspect:${injection.source}` }\n }\n if (LINK_SIGNAL.test(text) || LINK_SIGNAL.test(normalized)) {\n return { tier: 'complex', confidence: 0.9, reason: 'contains-link' }\n }\n const complexSignal = matchSignal(COMPLEX_SIGNALS, text, normalized)\n if (complexSignal) {\n return { tier: 'complex', confidence: 0.9, reason: `matched:${complexSignal.source}` }\n }\n // Long messages or multi-question threads are more likely to need nuance.\n const questionCount = (text.match(/\\?/g) ?? []).length\n if (text.length > 600 || questionCount > 2) {\n return { tier: 'complex', confidence: 0.6, reason: 'long-or-multi-question' }\n }\n return { tier: 'easy', confidence: 0.8, reason: 'short-simple' }\n}\n\n/** Per-channel AI auto-reply is OFF unless explicitly enabled on channel state. */\nexport function isAiAutoReplyEnabled(channelState: unknown): boolean {\n const parsed = discordChannelStateSchema.safeParse(channelState ?? {})\n return parsed.success ? Boolean(parsed.data.aiAutoReplyEnabled) : false\n}\n\nexport function resolveAiAgentId(channelState: unknown): string | undefined {\n const parsed = discordChannelStateSchema.safeParse(channelState ?? {})\n return parsed.success ? parsed.data.aiAgentId : undefined\n}\n\n/** Whether a previous auto-reply attempt left a failure marker still to be cleared. */\nexport function hasStoredAutoReplyFailure(channelState: unknown): boolean {\n const parsed = discordChannelStateSchema.safeParse(channelState ?? {})\n return parsed.success ? parsed.data.aiAutoReplyLastError !== undefined : false\n}\n\nexport interface SubscriberResolver {\n resolve: <T = unknown>(name: string) => T\n container?: { resolve: <T = unknown>(name: string) => T }\n}\n\n/**\n * Soft presence check for the `ai_assistant` module. The module registers\n * `mcpToolRegistry` in DI, so a successful resolve means the peer is active.\n * Returns `false` (no-op) when it is absent \u2014 never throws.\n */\nexport function isAiAssistantAvailable(ctx: SubscriberResolver): boolean {\n const resolver =\n typeof ctx?.resolve === 'function'\n ? ctx.resolve\n : ctx?.container?.resolve\n ? ctx.container.resolve.bind(ctx.container)\n : null\n if (!resolver) return false\n try {\n return Boolean(resolver('mcpToolRegistry'))\n } catch {\n return false\n }\n}\n\nexport type { DiscordChannelState }\n"],
|
|
5
|
+
"mappings": "AAAA,SAAS,iCAA2D;AA4BpE,MAAM,kBAAkB;AAAA,EACtB;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF;AAKA,MAAM,oBAAoB;AAAA,EACxB;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF;AAIA,MAAM,cAAc;AAKpB,MAAM,kBAAkB;AAQxB,SAAS,2BAA2B,MAAsB;AACxD,SAAO,KACJ,UAAU,MAAM,EAChB,QAAQ,oDAAoD,EAAE,EAC9D,QAAQ,YAAY,EAAE,EACtB,UAAU,MAAM,EAChB,QAAQ,oBAAoB,EAAE,EAC9B,QAAQ,aAAa,GAAG,EACxB,KAAK;AACV;AAEA,SAAS,YAAY,SAAmB,KAAa,YAAmC;AACtF,aAAW,UAAU,SAAS;AAC5B,QAAI,OAAO,KAAK,GAAG,KAAK,OAAO,KAAK,UAAU,EAAG,QAAO;AAAA,EAC1D;AACA,SAAO;AACT;AAeO,SAAS,uBAAuB,MAAoC;AACzE,QAAM,QAAQ,QAAQ,IAAI,KAAK;AAC/B,MAAI,KAAK,WAAW,GAAG;AACrB,WAAO,EAAE,MAAM,WAAW,YAAY,GAAG,QAAQ,aAAa;AAAA,EAChE;AACA,MAAI,gBAAgB,KAAK,IAAI,GAAG;AAC9B,WAAO,EAAE,MAAM,WAAW,YAAY,MAAM,QAAQ,sBAAsB;AAAA,EAC5E;AACA,QAAM,aAAa,2BAA2B,IAAI;AAClD,QAAM,YAAY,YAAY,mBAAmB,MAAM,UAAU;AACjE,MAAI,WAAW;AACb,WAAO,EAAE,MAAM,WAAW,YAAY,MAAM,QAAQ,qBAAqB,UAAU,MAAM,GAAG;AAAA,EAC9F;AACA,MAAI,YAAY,KAAK,IAAI,KAAK,YAAY,KAAK,UAAU,GAAG;AAC1D,WAAO,EAAE,MAAM,WAAW,YAAY,KAAK,QAAQ,gBAAgB;AAAA,EACrE;AACA,QAAM,gBAAgB,YAAY,iBAAiB,MAAM,UAAU;AACnE,MAAI,eAAe;AACjB,WAAO,EAAE,MAAM,WAAW,YAAY,KAAK,QAAQ,WAAW,cAAc,MAAM,GAAG;AAAA,EACvF;AAEA,QAAM,iBAAiB,KAAK,MAAM,KAAK,KAAK,CAAC,GAAG;AAChD,MAAI,KAAK,SAAS,OAAO,gBAAgB,GAAG;AAC1C,WAAO,EAAE,MAAM,WAAW,YAAY,KAAK,QAAQ,yBAAyB;AAAA,EAC9E;AACA,SAAO,EAAE,MAAM,QAAQ,YAAY,KAAK,QAAQ,eAAe;AACjE;AAGO,SAAS,qBAAqB,cAAgC;AACnE,QAAM,SAAS,0BAA0B,UAAU,gBAAgB,CAAC,CAAC;AACrE,SAAO,OAAO,UAAU,QAAQ,OAAO,KAAK,kBAAkB,IAAI;AACpE;AAEO,SAAS,iBAAiB,cAA2C;AAC1E,QAAM,SAAS,0BAA0B,UAAU,gBAAgB,CAAC,CAAC;AACrE,SAAO,OAAO,UAAU,OAAO,KAAK,YAAY;AAClD;AAGO,SAAS,0BAA0B,cAAgC;AACxE,QAAM,SAAS,0BAA0B,UAAU,gBAAgB,CAAC,CAAC;AACrE,SAAO,OAAO,UAAU,OAAO,KAAK,yBAAyB,SAAY;AAC3E;AAYO,SAAS,uBAAuB,KAAkC;AACvE,QAAM,WACJ,OAAO,KAAK,YAAY,aACpB,IAAI,UACJ,KAAK,WAAW,UACd,IAAI,UAAU,QAAQ,KAAK,IAAI,SAAS,IACxC;AACR,MAAI,CAAC,SAAU,QAAO;AACtB,MAAI;AACF,WAAO,QAAQ,SAAS,iBAAiB,CAAC;AAAA,EAC5C,QAAQ;AACN,WAAO;AAAA,EACT;AACF;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { createLogger } from "@open-mercato/shared/lib/logger";
|
|
2
|
+
import {
|
|
3
|
+
COMMUNICATION_CHANNELS_SYSTEM_USER_ID,
|
|
4
|
+
resolveCommunicationChannelsSystemUserId
|
|
5
|
+
} from "@open-mercato/core/modules/communication_channels/lib/system-user";
|
|
6
|
+
import { CHANNEL_DISCORD_AI_AUTO_REPLY_RUN_FEATURE } from "./ai-features.js";
|
|
7
|
+
const logger = createLogger("channel_discord").child({ component: "ai-service-principal" });
|
|
8
|
+
const PROVIDER_SERVICE_PRINCIPAL_FEATURES = [
|
|
9
|
+
CHANNEL_DISCORD_AI_AUTO_REPLY_RUN_FEATURE
|
|
10
|
+
];
|
|
11
|
+
function tryResolveRbacService(resolver) {
|
|
12
|
+
try {
|
|
13
|
+
const service = resolver.resolve("rbacService");
|
|
14
|
+
return service && typeof service.loadAcl === "function" ? service : null;
|
|
15
|
+
} catch {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
async function resolveDiscordAiPrincipal(params) {
|
|
20
|
+
const { em, resolver, scope } = params;
|
|
21
|
+
const base = {
|
|
22
|
+
tenantId: scope.tenantId,
|
|
23
|
+
organizationId: scope.organizationId,
|
|
24
|
+
userId: COMMUNICATION_CHANNELS_SYSTEM_USER_ID,
|
|
25
|
+
features: [...PROVIDER_SERVICE_PRINCIPAL_FEATURES],
|
|
26
|
+
isSuperAdmin: false,
|
|
27
|
+
source: "provider_service_principal"
|
|
28
|
+
};
|
|
29
|
+
const botUserId = await resolveCommunicationChannelsSystemUserId(em, scope.tenantId, null);
|
|
30
|
+
if (botUserId === COMMUNICATION_CHANNELS_SYSTEM_USER_ID) return base;
|
|
31
|
+
const rbacService = tryResolveRbacService(resolver);
|
|
32
|
+
if (!rbacService) {
|
|
33
|
+
logger.debug("rbacService unavailable \u2014 running auto-reply under the provider service principal", {
|
|
34
|
+
tenantId: scope.tenantId
|
|
35
|
+
});
|
|
36
|
+
return { ...base, userId: botUserId };
|
|
37
|
+
}
|
|
38
|
+
let acl;
|
|
39
|
+
try {
|
|
40
|
+
acl = await rbacService.loadAcl(botUserId, {
|
|
41
|
+
tenantId: scope.tenantId,
|
|
42
|
+
organizationId: scope.organizationId
|
|
43
|
+
});
|
|
44
|
+
} catch (err) {
|
|
45
|
+
logger.warn("Failed to load the channel-bot ACL \u2014 falling back to the provider service principal", {
|
|
46
|
+
tenantId: scope.tenantId,
|
|
47
|
+
err
|
|
48
|
+
});
|
|
49
|
+
return { ...base, userId: botUserId };
|
|
50
|
+
}
|
|
51
|
+
if (acl.isSuperAdmin) {
|
|
52
|
+
logger.warn(
|
|
53
|
+
"Channel-bot user is a super-admin; clamping the auto-reply principal to its explicit features",
|
|
54
|
+
{ tenantId: scope.tenantId }
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
const features = Array.from(/* @__PURE__ */ new Set([...PROVIDER_SERVICE_PRINCIPAL_FEATURES, ...acl.features]));
|
|
58
|
+
return {
|
|
59
|
+
...base,
|
|
60
|
+
userId: botUserId,
|
|
61
|
+
features,
|
|
62
|
+
isSuperAdmin: false,
|
|
63
|
+
source: "channel_bot_user"
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
export {
|
|
67
|
+
PROVIDER_SERVICE_PRINCIPAL_FEATURES,
|
|
68
|
+
resolveDiscordAiPrincipal
|
|
69
|
+
};
|
|
70
|
+
//# sourceMappingURL=ai-service-principal.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../../src/modules/channel_discord/lib/ai-service-principal.ts"],
|
|
4
|
+
"sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\nimport {\n COMMUNICATION_CHANNELS_SYSTEM_USER_ID,\n resolveCommunicationChannelsSystemUserId,\n} from '@open-mercato/core/modules/communication_channels/lib/system-user'\nimport { CHANNEL_DISCORD_AI_AUTO_REPLY_RUN_FEATURE } from './ai-features'\n\nconst logger = createLogger('channel_discord').child({ component: 'ai-service-principal' })\n\n/**\n * The auth context the AI auto-reply subscriber runs an agent under.\n *\n * Field names match `AiChatRequestContext` from `@open-mercato/ai-assistant` on\n * purpose \u2014 the subscriber hands this object straight to `runAiAgentObject` \u2014\n * but the type is declared locally so the optional peer stays optional.\n */\nexport type DiscordAiPrincipal = {\n tenantId: string\n organizationId: string | null\n userId: string\n features: string[]\n isSuperAdmin: boolean\n /** Which of the two identities below produced the grants. Logged, never sent to the model. */\n source: 'channel_bot_user' | 'provider_service_principal'\n}\n\n/**\n * The grants the provider's own service principal carries by construction, with\n * no operator setup. Exactly one non-data feature: it unlocks the provider's own\n * object-mode auto-reply agent and nothing else. Any agent gated on anything\n * broader \u2014 a customers support agent, say \u2014 is NOT reachable from this identity\n * and needs the channel-bot user path below.\n */\nconst PROVIDER_SERVICE_PRINCIPAL_FEATURES: readonly string[] = [\n CHANNEL_DISCORD_AI_AUTO_REPLY_RUN_FEATURE,\n]\n\ntype AclSnapshot = { features: string[]; isSuperAdmin: boolean }\n\ntype RbacServiceLike = {\n loadAcl: (\n userId: string,\n scope: { tenantId: string | null; organizationId: string | null },\n ) => Promise<AclSnapshot & { organizations: string[] | null }>\n}\n\nexport type DiscordAiPrincipalResolver = {\n resolve: <T = unknown>(name: string) => T\n}\n\nfunction tryResolveRbacService(resolver: DiscordAiPrincipalResolver): RbacServiceLike | null {\n try {\n const service = resolver.resolve<RbacServiceLike>('rbacService')\n return service && typeof service.loadAcl === 'function' ? service : null\n } catch {\n return null\n }\n}\n\n/**\n * Resolve the identity an inbound Discord message may borrow to have a reply\n * drafted (SPEC 2026-06-19 \u00A7 AI bot wiring \u2014 \"Safety / RBAC\", issue #4778).\n *\n * There are two identities, and the difference between them is the whole point:\n *\n * 1. **Channel-bot user** \u2014 a real `auth.user` row following the hub's\n * `system+communication_channels@<tenantId>.local` convention. When a tenant\n * creates one, its role grants are loaded through `rbacService.loadAcl` and\n * become the principal's features. This is the supported way to let auto-reply\n * use an agent this provider does not own: grant the bot user the role that\n * agent's `requiredFeatures` need.\n * 2. **Provider service principal** \u2014 the fallback when no such user exists.\n * It carries {@link PROVIDER_SERVICE_PRINCIPAL_FEATURES} only.\n *\n * Two invariants hold on both paths:\n *\n * - `isSuperAdmin` is **always** false. An inbound message from a public Discord\n * server must not be able to borrow a super-admin's ACL, so a bot user who\n * happens to be one is clamped down (and the clamp is logged, because it means\n * the tenant gave a bot account far more than it needs).\n * - The provider grant is always present. It is what makes the provider's own\n * agent work out of the box; it can never widen access to anything else,\n * because no tool or route is gated on it.\n */\nexport async function resolveDiscordAiPrincipal(params: {\n em: EntityManager\n resolver: DiscordAiPrincipalResolver\n scope: { tenantId: string; organizationId: string | null }\n}): Promise<DiscordAiPrincipal> {\n const { em, resolver, scope } = params\n const base: DiscordAiPrincipal = {\n tenantId: scope.tenantId,\n organizationId: scope.organizationId,\n userId: COMMUNICATION_CHANNELS_SYSTEM_USER_ID,\n features: [...PROVIDER_SERVICE_PRINCIPAL_FEATURES],\n isSuperAdmin: false,\n source: 'provider_service_principal',\n }\n\n const botUserId = await resolveCommunicationChannelsSystemUserId(em, scope.tenantId, null)\n if (botUserId === COMMUNICATION_CHANNELS_SYSTEM_USER_ID) return base\n\n const rbacService = tryResolveRbacService(resolver)\n if (!rbacService) {\n logger.debug('rbacService unavailable \u2014 running auto-reply under the provider service principal', {\n tenantId: scope.tenantId,\n })\n return { ...base, userId: botUserId }\n }\n\n let acl: AclSnapshot\n try {\n acl = await rbacService.loadAcl(botUserId, {\n tenantId: scope.tenantId,\n organizationId: scope.organizationId,\n })\n } catch (err) {\n logger.warn('Failed to load the channel-bot ACL \u2014 falling back to the provider service principal', {\n tenantId: scope.tenantId,\n err,\n })\n return { ...base, userId: botUserId }\n }\n\n if (acl.isSuperAdmin) {\n logger.warn(\n 'Channel-bot user is a super-admin; clamping the auto-reply principal to its explicit features',\n { tenantId: scope.tenantId },\n )\n }\n\n const features = Array.from(new Set([...PROVIDER_SERVICE_PRINCIPAL_FEATURES, ...acl.features]))\n return {\n ...base,\n userId: botUserId,\n features,\n isSuperAdmin: false,\n source: 'channel_bot_user',\n }\n}\n\nexport { PROVIDER_SERVICE_PRINCIPAL_FEATURES }\n"],
|
|
5
|
+
"mappings": "AACA,SAAS,oBAAoB;AAC7B;AAAA,EACE;AAAA,EACA;AAAA,OACK;AACP,SAAS,iDAAiD;AAE1D,MAAM,SAAS,aAAa,iBAAiB,EAAE,MAAM,EAAE,WAAW,uBAAuB,CAAC;AA0B1F,MAAM,sCAAyD;AAAA,EAC7D;AACF;AAeA,SAAS,sBAAsB,UAA8D;AAC3F,MAAI;AACF,UAAM,UAAU,SAAS,QAAyB,aAAa;AAC/D,WAAO,WAAW,OAAO,QAAQ,YAAY,aAAa,UAAU;AAAA,EACtE,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AA2BA,eAAsB,0BAA0B,QAIhB;AAC9B,QAAM,EAAE,IAAI,UAAU,MAAM,IAAI;AAChC,QAAM,OAA2B;AAAA,IAC/B,UAAU,MAAM;AAAA,IAChB,gBAAgB,MAAM;AAAA,IACtB,QAAQ;AAAA,IACR,UAAU,CAAC,GAAG,mCAAmC;AAAA,IACjD,cAAc;AAAA,IACd,QAAQ;AAAA,EACV;AAEA,QAAM,YAAY,MAAM,yCAAyC,IAAI,MAAM,UAAU,IAAI;AACzF,MAAI,cAAc,sCAAuC,QAAO;AAEhE,QAAM,cAAc,sBAAsB,QAAQ;AAClD,MAAI,CAAC,aAAa;AAChB,WAAO,MAAM,0FAAqF;AAAA,MAChG,UAAU,MAAM;AAAA,IAClB,CAAC;AACD,WAAO,EAAE,GAAG,MAAM,QAAQ,UAAU;AAAA,EACtC;AAEA,MAAI;AACJ,MAAI;AACF,UAAM,MAAM,YAAY,QAAQ,WAAW;AAAA,MACzC,UAAU,MAAM;AAAA,MAChB,gBAAgB,MAAM;AAAA,IACxB,CAAC;AAAA,EACH,SAAS,KAAK;AACZ,WAAO,KAAK,4FAAuF;AAAA,MACjG,UAAU,MAAM;AAAA,MAChB;AAAA,IACF,CAAC;AACD,WAAO,EAAE,GAAG,MAAM,QAAQ,UAAU;AAAA,EACtC;AAEA,MAAI,IAAI,cAAc;AACpB,WAAO;AAAA,MACL;AAAA,MACA,EAAE,UAAU,MAAM,SAAS;AAAA,IAC7B;AAAA,EACF;AAEA,QAAM,WAAW,MAAM,KAAK,oBAAI,IAAI,CAAC,GAAG,qCAAqC,GAAG,IAAI,QAAQ,CAAC,CAAC;AAC9F,SAAO;AAAA,IACL,GAAG;AAAA,IACH,QAAQ;AAAA,IACR;AAAA,IACA,cAAc;AAAA,IACd,QAAQ;AAAA,EACV;AACF;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
const DISCORD_MAX_BODY_LENGTH = 2e3;
|
|
2
|
+
const discordCapabilities = {
|
|
3
|
+
// A Discord recipient is a channel snowflake, never an address. Without this
|
|
4
|
+
// the hub falls back to its `'email'` default and `validateOutboundRecipient`
|
|
5
|
+
// rejects every real recipient with "Recipient must be a valid email address"
|
|
6
|
+
// — which is #4976 still broken for the provider it was filed against, even
|
|
7
|
+
// though #5261 built the mechanism to fix it. The hub then applies transport
|
|
8
|
+
// safety only (allowlist + length), and this adapter owns the format and must
|
|
9
|
+
// keep treating the value as untrusted.
|
|
10
|
+
recipientFormat: "provider-native",
|
|
11
|
+
// Core
|
|
12
|
+
threading: false,
|
|
13
|
+
richText: true,
|
|
14
|
+
fileSharing: false,
|
|
15
|
+
readReceipts: false,
|
|
16
|
+
deliveryReceipts: false,
|
|
17
|
+
typingIndicators: false,
|
|
18
|
+
// Extended
|
|
19
|
+
reactions: true,
|
|
20
|
+
multiReactionPerUser: false,
|
|
21
|
+
editMessage: true,
|
|
22
|
+
deleteMessage: true,
|
|
23
|
+
presence: false,
|
|
24
|
+
richBlocks: false,
|
|
25
|
+
interactiveComponents: true,
|
|
26
|
+
inlineImages: false,
|
|
27
|
+
conversationHistory: true,
|
|
28
|
+
contactCards: false,
|
|
29
|
+
locationSharing: false,
|
|
30
|
+
voiceNotes: false,
|
|
31
|
+
stickers: false,
|
|
32
|
+
// Content format support
|
|
33
|
+
supportedBodyFormats: ["text", "markdown"],
|
|
34
|
+
maxBodyLength: DISCORD_MAX_BODY_LENGTH,
|
|
35
|
+
// The gateway worker is the real-time source; the hub must not schedule polling.
|
|
36
|
+
realtimePush: true
|
|
37
|
+
};
|
|
38
|
+
export {
|
|
39
|
+
DISCORD_MAX_BODY_LENGTH,
|
|
40
|
+
discordCapabilities
|
|
41
|
+
};
|
|
42
|
+
//# sourceMappingURL=capabilities.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../../src/modules/channel_discord/lib/capabilities.ts"],
|
|
4
|
+
"sourcesContent": ["import type { ChannelCapabilities } from '@open-mercato/core/modules/communication_channels/lib/adapter'\n\n/**\n * Discord message content hard limit \u2014 the REST API rejects `content` longer\n * than 2000 characters (embeds have their own separate budget we don't use here).\n */\nexport const DISCORD_MAX_BODY_LENGTH = 2000\n\n/**\n * Discord capability profile (SPEC 2026-06-19 \u00A7 Adapter method map).\n *\n * The hub treats these flags as a contract: it routes work to the adapter based\n * on them, so each one describes what THIS adapter implements today, not what\n * the Discord API is able to do. Everything the first release does not implement\n * is declared `false` and stays `false` until the corresponding code lands.\n *\n * Enabled, with the implementation behind each:\n * - `richText` / `supportedBodyFormats: ['text', 'markdown']` \u2014 Discord content\n * is markdown-native; HTML is down-converted in `convertOutbound`.\n * - `reactions` / `editMessage` / `deleteMessage` \u2014 backed by the matching\n * `discord-rest` calls the adapter exposes.\n * - `conversationHistory` \u2014 `fetchHistory` pages `GET /channels/{id}/messages`.\n * - `realtimePush` \u2014 the provider owns a long-running Gateway WebSocket worker\n * that delivers `MESSAGE_CREATE` / reaction events in real time, so the hub's\n * polling scheduler skips this channel (no redundant `fetchHistory`).\n * - `interactiveComponents` \u2014 slash commands, buttons, select menus and modal\n * submissions round-trip for real: the signed Interactions endpoint answers\n * with a deferred acknowledgement AND hands the interaction to\n * `workers/discord-interactions.ts`, which normalizes it into the hub's\n * existing inbound queue under the matched channel's tenant scope and then\n * replaces the \"thinking\u2026\" placeholder over Discord's interaction-webhook\n * endpoints. Pinned by the parity test in `lib/__tests__/capabilities.test.ts`,\n * which drives the whole path rather than asserting the flag alone.\n *\n * Deliberately disabled until implemented (declaring them would make the hub\n * hand this adapter work it silently drops):\n * - `threading` \u2014 `convertOutbound` reads `channelMetadata.replyToExternalId`\n * and emits `message_reference` from it, but nothing hub-side ever writes that\n * key into OUTBOUND metadata: `replyToExternalId` exists only on the inbound\n * `NormalizedInboundMessage` shape, and the hub's outbound metadata producers\n * (`send-as-user.ts`, `deliver-outbound-message.ts`) write the email-shaped\n * `inReplyTo` / `references` instead. The conversion is therefore unreachable\n * in production \u2014 confirmed against a live bot in #5541. The flag flips back\n * to `true` in the same change that gives the hub an outbound reply producer\n * this adapter can read, guarded by a contract test.\n * - `fileSharing` / `inlineImages` \u2014 `convertOutbound` drops\n * `input.content.attachments` and `discord-rest` has no multipart upload, so\n * outbound attachments never reach Discord. `maxFileSize` /\n * `supportedMimeTypes` are omitted for the same reason.\n * - `typingIndicators` \u2014 no `POST /channels/{id}/typing` call exists here.\n * - `presence` \u2014 the bot identifies without the `GUILD_PRESENCES` intent and no\n * presence dispatch is handled.\n * - `richBlocks` \u2014 outbound is plain markdown `content`; embeds are not built.\n * - `stickers` \u2014 no sticker is sent or normalized.\n *\n * AI auto-reply (issue #4778) deliberately flips NONE of these back to `true`.\n * `ChannelCapabilities` describes what the hub may hand this ADAPTER \u2014 message\n * shapes, transport features, whether to schedule polling. Auto-reply is not an\n * adapter capability at all: it is a subscriber on the hub's generic\n * `message.received` event that composes a reply through the same outbound path\n * any module uses. Adding a flag for it would tell the hub to route work here\n * that the adapter does not implement, which is exactly the failure mode this\n * list exists to prevent.\n */\nexport const discordCapabilities: ChannelCapabilities = {\n // A Discord recipient is a channel snowflake, never an address. Without this\n // the hub falls back to its `'email'` default and `validateOutboundRecipient`\n // rejects every real recipient with \"Recipient must be a valid email address\"\n // \u2014 which is #4976 still broken for the provider it was filed against, even\n // though #5261 built the mechanism to fix it. The hub then applies transport\n // safety only (allowlist + length), and this adapter owns the format and must\n // keep treating the value as untrusted.\n recipientFormat: 'provider-native',\n\n // Core\n threading: false,\n richText: true,\n fileSharing: false,\n readReceipts: false,\n deliveryReceipts: false,\n typingIndicators: false,\n\n // Extended\n reactions: true,\n multiReactionPerUser: false,\n editMessage: true,\n deleteMessage: true,\n presence: false,\n richBlocks: false,\n interactiveComponents: true,\n inlineImages: false,\n conversationHistory: true,\n contactCards: false,\n locationSharing: false,\n voiceNotes: false,\n stickers: false,\n\n // Content format support\n supportedBodyFormats: ['text', 'markdown'],\n maxBodyLength: DISCORD_MAX_BODY_LENGTH,\n\n // The gateway worker is the real-time source; the hub must not schedule polling.\n realtimePush: true,\n}\n"],
|
|
5
|
+
"mappings": "AAMO,MAAM,0BAA0B;AA0DhC,MAAM,sBAA2C;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAQtD,iBAAiB;AAAA;AAAA,EAGjB,WAAW;AAAA,EACX,UAAU;AAAA,EACV,aAAa;AAAA,EACb,cAAc;AAAA,EACd,kBAAkB;AAAA,EAClB,kBAAkB;AAAA;AAAA,EAGlB,WAAW;AAAA,EACX,sBAAsB;AAAA,EACtB,aAAa;AAAA,EACb,eAAe;AAAA,EACf,UAAU;AAAA,EACV,YAAY;AAAA,EACZ,uBAAuB;AAAA,EACvB,cAAc;AAAA,EACd,qBAAqB;AAAA,EACrB,cAAc;AAAA,EACd,iBAAiB;AAAA,EACjB,YAAY;AAAA,EACZ,UAAU;AAAA;AAAA,EAGV,sBAAsB,CAAC,QAAQ,UAAU;AAAA,EACzC,eAAe;AAAA;AAAA,EAGf,cAAc;AAChB;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { NextResponse } from "next/server";
|
|
2
|
+
import { findOneWithDecryption } from "@open-mercato/shared/lib/encryption/find";
|
|
3
|
+
import { resolveOrganizationScopeForRequest } from "@open-mercato/core/modules/directory/utils/organizationScope";
|
|
4
|
+
import { resolveOrganizationScopeFilter } from "@open-mercato/core/modules/directory/utils/organizationScopeFilter";
|
|
5
|
+
import { CommunicationChannel } from "@open-mercato/core/modules/communication_channels/data/entities";
|
|
6
|
+
import {
|
|
7
|
+
assertCanAccessChannel,
|
|
8
|
+
assertCanManageChannel,
|
|
9
|
+
channelOrgScopeWhereFromFilter,
|
|
10
|
+
ChannelAccessDeniedError
|
|
11
|
+
} from "@open-mercato/core/modules/communication_channels/lib/access-control";
|
|
12
|
+
async function loadDiscordChannelForRequest(params) {
|
|
13
|
+
const { container, req, auth, channelId, mode } = params;
|
|
14
|
+
const notFound = { response: NextResponse.json({ error: "Channel not found" }, { status: 404 }) };
|
|
15
|
+
const em = container.resolve("em").fork();
|
|
16
|
+
const scope = await resolveOrganizationScopeForRequest({ container, auth, request: req });
|
|
17
|
+
const orgFilter = resolveOrganizationScopeFilter(scope, auth);
|
|
18
|
+
const channel = await findOneWithDecryption(
|
|
19
|
+
em,
|
|
20
|
+
CommunicationChannel,
|
|
21
|
+
{
|
|
22
|
+
id: channelId,
|
|
23
|
+
tenantId: auth.tenantId,
|
|
24
|
+
providerKey: "discord",
|
|
25
|
+
...channelOrgScopeWhereFromFilter(orgFilter),
|
|
26
|
+
deletedAt: null
|
|
27
|
+
},
|
|
28
|
+
void 0,
|
|
29
|
+
{ tenantId: auth.tenantId, organizationId: orgFilter.rbacOrganizationId }
|
|
30
|
+
);
|
|
31
|
+
if (!channel) return notFound;
|
|
32
|
+
let userFeatures = [];
|
|
33
|
+
try {
|
|
34
|
+
const rbac = container.resolve("rbacService");
|
|
35
|
+
const acl = await rbac.loadAcl(auth.sub, {
|
|
36
|
+
tenantId: auth.tenantId,
|
|
37
|
+
organizationId: orgFilter.rbacOrganizationId
|
|
38
|
+
});
|
|
39
|
+
userFeatures = acl.features;
|
|
40
|
+
} catch {
|
|
41
|
+
userFeatures = [];
|
|
42
|
+
}
|
|
43
|
+
try {
|
|
44
|
+
if (mode === "manage") {
|
|
45
|
+
assertCanManageChannel(channel, auth.sub ?? null, userFeatures, "communication_channels.manage");
|
|
46
|
+
} else {
|
|
47
|
+
assertCanAccessChannel(channel, auth.sub ?? null, userFeatures);
|
|
48
|
+
}
|
|
49
|
+
} catch (err) {
|
|
50
|
+
if (err instanceof ChannelAccessDeniedError) return notFound;
|
|
51
|
+
throw err;
|
|
52
|
+
}
|
|
53
|
+
return {
|
|
54
|
+
channel,
|
|
55
|
+
organizationIds: orgFilter.organizationIds ?? null,
|
|
56
|
+
rbacOrganizationId: orgFilter.rbacOrganizationId
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
export {
|
|
60
|
+
loadDiscordChannelForRequest
|
|
61
|
+
};
|
|
62
|
+
//# sourceMappingURL=channel-access.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../../src/modules/channel_discord/lib/channel-access.ts"],
|
|
4
|
+
"sourcesContent": ["import type { AwilixContainer } from 'awilix'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport { NextResponse } from 'next/server'\nimport { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport { resolveOrganizationScopeForRequest } from '@open-mercato/core/modules/directory/utils/organizationScope'\nimport { resolveOrganizationScopeFilter } from '@open-mercato/core/modules/directory/utils/organizationScopeFilter'\nimport { CommunicationChannel } from '@open-mercato/core/modules/communication_channels/data/entities'\nimport {\n assertCanAccessChannel,\n assertCanManageChannel,\n channelOrgScopeWhereFromFilter,\n ChannelAccessDeniedError,\n} from '@open-mercato/core/modules/communication_channels/lib/access-control'\n\ntype RouteAuth = {\n sub?: string | null\n tenantId?: string | null\n orgId?: string | null\n}\n\ntype RbacServiceLike = {\n loadAcl: (\n userId: string,\n scope: { tenantId: string | null; organizationId: string | null },\n ) => Promise<{ isSuperAdmin: boolean; features: string[]; organizations: string[] | null }>\n}\n\nexport type DiscordChannelAccess = {\n channel: CommunicationChannel\n /** Organizations the caller may see, for the command's own scoped re-lookup. */\n organizationIds: string[] | null\n /** Organization the decryption scope is anchored to. */\n rbacOrganizationId: string | null\n}\n\n/**\n * Load a Discord channel for a provider-owned route, scoped and authorized the\n * same way the hub's own channel-scoped routes do.\n *\n * Reusing the hub's helpers rather than filtering on `auth.orgId` matters twice\n * over. A Discord bot channel is usually tenant-scoped (`organization_id IS\n * NULL`), so an equality filter on the caller's selected organization would hide\n * exactly the channels this feature configures (#5012 fixed the same bug on the\n * hub's detail route). And a per-user channel belongs to its owner: the shared\n * access guards are what stop one operator from arming an AI on another's\n * mailbox, which no feature grant should be able to override.\n *\n * Returns `{ response }` with a masked 404 when the channel is absent or the\n * caller may not touch it \u2014 existence masking, consistent with the hub.\n */\nexport async function loadDiscordChannelForRequest(params: {\n container: AwilixContainer\n req: Request\n auth: RouteAuth\n channelId: string\n mode: 'read' | 'manage'\n}): Promise<DiscordChannelAccess | { response: Response }> {\n const { container, req, auth, channelId, mode } = params\n const notFound = { response: NextResponse.json({ error: 'Channel not found' }, { status: 404 }) }\n\n const em = (container.resolve('em') as EntityManager).fork()\n const scope = await resolveOrganizationScopeForRequest({ container, auth: auth as never, request: req })\n const orgFilter = resolveOrganizationScopeFilter(scope, auth as never)\n\n const channel = await findOneWithDecryption(\n em,\n CommunicationChannel,\n {\n id: channelId,\n tenantId: auth.tenantId as string,\n providerKey: 'discord',\n ...channelOrgScopeWhereFromFilter(orgFilter),\n deletedAt: null,\n },\n undefined,\n { tenantId: auth.tenantId as string, organizationId: orgFilter.rbacOrganizationId },\n )\n if (!channel) return notFound\n\n let userFeatures: string[] = []\n try {\n const rbac = container.resolve('rbacService') as RbacServiceLike\n const acl = await rbac.loadAcl(auth.sub as string, {\n tenantId: auth.tenantId as string,\n organizationId: orgFilter.rbacOrganizationId,\n })\n userFeatures = acl.features\n } catch {\n // A failed ACL load leaves `userFeatures` empty, which only makes the guard\n // below stricter \u2014 it can deny, never grant.\n userFeatures = []\n }\n\n try {\n if (mode === 'manage') {\n assertCanManageChannel(channel, auth.sub ?? null, userFeatures, 'communication_channels.manage')\n } else {\n assertCanAccessChannel(channel, auth.sub ?? null, userFeatures)\n }\n } catch (err) {\n if (err instanceof ChannelAccessDeniedError) return notFound\n throw err\n }\n\n return {\n channel,\n organizationIds: orgFilter.organizationIds ?? null,\n rbacOrganizationId: orgFilter.rbacOrganizationId,\n }\n}\n"],
|
|
5
|
+
"mappings": "AAEA,SAAS,oBAAoB;AAC7B,SAAS,6BAA6B;AACtC,SAAS,0CAA0C;AACnD,SAAS,sCAAsC;AAC/C,SAAS,4BAA4B;AACrC;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AAsCP,eAAsB,6BAA6B,QAMQ;AACzD,QAAM,EAAE,WAAW,KAAK,MAAM,WAAW,KAAK,IAAI;AAClD,QAAM,WAAW,EAAE,UAAU,aAAa,KAAK,EAAE,OAAO,oBAAoB,GAAG,EAAE,QAAQ,IAAI,CAAC,EAAE;AAEhG,QAAM,KAAM,UAAU,QAAQ,IAAI,EAAoB,KAAK;AAC3D,QAAM,QAAQ,MAAM,mCAAmC,EAAE,WAAW,MAAqB,SAAS,IAAI,CAAC;AACvG,QAAM,YAAY,+BAA+B,OAAO,IAAa;AAErE,QAAM,UAAU,MAAM;AAAA,IACpB;AAAA,IACA;AAAA,IACA;AAAA,MACE,IAAI;AAAA,MACJ,UAAU,KAAK;AAAA,MACf,aAAa;AAAA,MACb,GAAG,+BAA+B,SAAS;AAAA,MAC3C,WAAW;AAAA,IACb;AAAA,IACA;AAAA,IACA,EAAE,UAAU,KAAK,UAAoB,gBAAgB,UAAU,mBAAmB;AAAA,EACpF;AACA,MAAI,CAAC,QAAS,QAAO;AAErB,MAAI,eAAyB,CAAC;AAC9B,MAAI;AACF,UAAM,OAAO,UAAU,QAAQ,aAAa;AAC5C,UAAM,MAAM,MAAM,KAAK,QAAQ,KAAK,KAAe;AAAA,MACjD,UAAU,KAAK;AAAA,MACf,gBAAgB,UAAU;AAAA,IAC5B,CAAC;AACD,mBAAe,IAAI;AAAA,EACrB,QAAQ;AAGN,mBAAe,CAAC;AAAA,EAClB;AAEA,MAAI;AACF,QAAI,SAAS,UAAU;AACrB,6BAAuB,SAAS,KAAK,OAAO,MAAM,cAAc,+BAA+B;AAAA,IACjG,OAAO;AACL,6BAAuB,SAAS,KAAK,OAAO,MAAM,YAAY;AAAA,IAChE;AAAA,EACF,SAAS,KAAK;AACZ,QAAI,eAAe,yBAA0B,QAAO;AACpD,UAAM;AAAA,EACR;AAEA,SAAO;AAAA,IACL;AAAA,IACA,iBAAiB,UAAU,mBAAmB;AAAA,IAC9C,oBAAoB,UAAU;AAAA,EAChC;AACF;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|