@open-mercato/channel-discord 0.9.1-develop.7515.1.e2712c8b00 → 0.9.1-develop.7517.1.3d0995a8ca
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/dist/modules/channel_discord/commands/ai-reply-proposal.js +5 -0
- package/dist/modules/channel_discord/commands/ai-reply-proposal.js.map +2 -2
- package/package.json +8 -8
- package/src/modules/channel_discord/commands/__tests__/ai-reply-proposal.test.ts +33 -0
- package/src/modules/channel_discord/commands/ai-reply-proposal.ts +9 -0
|
@@ -3,6 +3,7 @@ import { registerCommand } from "@open-mercato/shared/lib/commands";
|
|
|
3
3
|
import { createLogger } from "@open-mercato/shared/lib/logger";
|
|
4
4
|
import { findOneWithDecryption } from "@open-mercato/shared/lib/encryption/find";
|
|
5
5
|
import { Message } from "@open-mercato/core/modules/messages/data/entities";
|
|
6
|
+
import { resolveMessageReadAccess } from "@open-mercato/core/modules/messages/lib/routeHelpers";
|
|
6
7
|
import {
|
|
7
8
|
CHANNEL_DISCORD_AI_PROPOSAL_APPROVE_COMMAND_ID,
|
|
8
9
|
CHANNEL_DISCORD_AI_PROPOSAL_DISMISS_COMMAND_ID
|
|
@@ -64,6 +65,10 @@ const approveProposalCommand = {
|
|
|
64
65
|
if (!inbound) {
|
|
65
66
|
throw new Error("[internal] The message this proposal answers no longer exists");
|
|
66
67
|
}
|
|
68
|
+
const access = await resolveMessageReadAccess(ctx, scope, inbound);
|
|
69
|
+
if (!access.canRead) {
|
|
70
|
+
throw new Error("[internal] The approving operator cannot read the message this proposal answers");
|
|
71
|
+
}
|
|
67
72
|
const body = (proposal.body ?? "").trim();
|
|
68
73
|
if (!body) {
|
|
69
74
|
throw new Error("[internal] AI reply proposal has an empty body");
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
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 { DISCORD_CHANNEL_TYPE } from '../lib/channel-identity'\nimport { hasDiscordProposalOrigin } from '../lib/ai-proposal-origin'\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, organizationId: scope.organizationId, 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 if (!hasDiscordProposalOrigin(proposal)) {\n throw new Error('[internal] Discord AI reply proposal has no valid system origin')\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, organizationId: scope.organizationId, 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 // Same waiver the automatic tier needs (#5601). The approve path composes\n // the identical public reply through the identical hub command, so an\n // absent channel type breaks it identically: the validator fails closed\n // and demands an `externalEmail` from a snowflake. The human-approved\n // send was as broken as the automatic one.\n sourceChannelType: DISCORD_CHANNEL_TYPE,\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,4BAA4B;AACrC,SAAS,gCAAgC;AACzC,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,gBAAgB,MAAM,gBAAgB,WAAW,KAAK;AAAA,IACjG;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,MAAI,CAAC,yBAAyB,QAAQ,GAAG;AACvC,UAAM,IAAI,MAAM,iEAAiE;AAAA,EACnF;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,gBAAgB,MAAM,gBAAgB,WAAW,KAAK;AAAA,MACxG;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;AAAA;AAAA;AAAA;AAAA;AAAA,QAMZ,mBAAmB;AAAA,QACnB,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;",
|
|
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 { resolveMessageReadAccess } from '@open-mercato/core/modules/messages/lib/routeHelpers'\nimport {\n CHANNEL_DISCORD_AI_PROPOSAL_APPROVE_COMMAND_ID,\n CHANNEL_DISCORD_AI_PROPOSAL_DISMISS_COMMAND_ID,\n} from '../lib/ai-proposal-contract'\nimport { DISCORD_CHANNEL_TYPE } from '../lib/channel-identity'\nimport { hasDiscordProposalOrigin } from '../lib/ai-proposal-origin'\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, organizationId: scope.organizationId, 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 if (!hasDiscordProposalOrigin(proposal)) {\n throw new Error('[internal] Discord AI reply proposal has no valid system origin')\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, organizationId: scope.organizationId, 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 // The send joins the answered message's thread, so the approving operator\n // must be able to read that message \u2014 the same rule a compose naming it as\n // its parent is held to. Access revoked after the proposal was filed refuses\n // the send instead of posting into a conversation the operator lost.\n const access = await resolveMessageReadAccess(ctx, scope, inbound)\n if (!access.canRead) {\n throw new Error('[internal] The approving operator cannot read the message this proposal answers')\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 // Same waiver the automatic tier needs (#5601). The approve path composes\n // the identical public reply through the identical hub command, so an\n // absent channel type breaks it identically: the validator fails closed\n // and demands an `externalEmail` from a snowflake. The human-approved\n // send was as broken as the automatic one.\n sourceChannelType: DISCORD_CHANNEL_TYPE,\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": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/channel-discord",
|
|
3
|
-
"version": "0.9.1-develop.
|
|
3
|
+
"version": "0.9.1-develop.7517.1.3d0995a8ca",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -62,14 +62,14 @@
|
|
|
62
62
|
}
|
|
63
63
|
},
|
|
64
64
|
"dependencies": {
|
|
65
|
-
"@open-mercato/core": "0.9.1-develop.
|
|
66
|
-
"@open-mercato/queue": "0.9.1-develop.
|
|
67
|
-
"@open-mercato/ui": "0.9.1-develop.
|
|
65
|
+
"@open-mercato/core": "0.9.1-develop.7517.1.3d0995a8ca",
|
|
66
|
+
"@open-mercato/queue": "0.9.1-develop.7517.1.3d0995a8ca",
|
|
67
|
+
"@open-mercato/ui": "0.9.1-develop.7517.1.3d0995a8ca"
|
|
68
68
|
},
|
|
69
69
|
"peerDependencies": {
|
|
70
70
|
"@mikro-orm/postgresql": "^7.0.14",
|
|
71
|
-
"@open-mercato/ai-assistant": "0.9.1-develop.
|
|
72
|
-
"@open-mercato/shared": "0.9.1-develop.
|
|
71
|
+
"@open-mercato/ai-assistant": "0.9.1-develop.7517.1.3d0995a8ca",
|
|
72
|
+
"@open-mercato/shared": "0.9.1-develop.7517.1.3d0995a8ca",
|
|
73
73
|
"react": "^19.0.0",
|
|
74
74
|
"react-dom": "^19.0.0"
|
|
75
75
|
},
|
|
@@ -79,8 +79,8 @@
|
|
|
79
79
|
}
|
|
80
80
|
},
|
|
81
81
|
"devDependencies": {
|
|
82
|
-
"@open-mercato/ai-assistant": "0.9.1-develop.
|
|
83
|
-
"@open-mercato/shared": "0.9.1-develop.
|
|
82
|
+
"@open-mercato/ai-assistant": "0.9.1-develop.7517.1.3d0995a8ca",
|
|
83
|
+
"@open-mercato/shared": "0.9.1-develop.7517.1.3d0995a8ca",
|
|
84
84
|
"@types/jest": "^30.0.0",
|
|
85
85
|
"@types/react": "^19.2.18",
|
|
86
86
|
"@types/react-dom": "^19.2.5",
|
|
@@ -10,14 +10,20 @@ jest.mock('@open-mercato/shared/lib/encryption/find', () => ({
|
|
|
10
10
|
findOneWithDecryption: jest.fn(),
|
|
11
11
|
}))
|
|
12
12
|
|
|
13
|
+
jest.mock('@open-mercato/core/modules/messages/lib/routeHelpers', () => ({
|
|
14
|
+
resolveMessageReadAccess: jest.fn(),
|
|
15
|
+
}))
|
|
16
|
+
|
|
13
17
|
import { approveProposalCommand, dismissProposalCommand } from '../ai-reply-proposal'
|
|
14
18
|
import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
|
|
15
19
|
import { composeMessageSchema } from '@open-mercato/core/modules/messages/data/validators'
|
|
20
|
+
import { resolveMessageReadAccess } from '@open-mercato/core/modules/messages/lib/routeHelpers'
|
|
16
21
|
import { CHANNEL_DISCORD_AI_PROPOSAL_MESSAGE_TYPE } from '../../message-types'
|
|
17
22
|
import { createDiscordProposalOriginKey } from '../../lib/ai-proposal-origin'
|
|
18
23
|
import { CHANNEL_DISCORD_AI_PROPOSAL_SOURCE_ENTITY_TYPE } from '../../lib/ai-proposal-contract'
|
|
19
24
|
|
|
20
25
|
const findOne = findOneWithDecryption as unknown as jest.Mock
|
|
26
|
+
const readAccess = resolveMessageReadAccess as unknown as jest.Mock
|
|
21
27
|
|
|
22
28
|
const TENANT = '11111111-1111-4111-8111-111111111111'
|
|
23
29
|
const ORG = '22222222-2222-4222-8222-222222222222'
|
|
@@ -73,6 +79,8 @@ const actionInput = { messageId: PROPOSAL_ID, actionId: 'approve_send' }
|
|
|
73
79
|
describe('channel_discord.ai_reply_proposal.approve', () => {
|
|
74
80
|
beforeEach(() => {
|
|
75
81
|
findOne.mockReset()
|
|
82
|
+
readAccess.mockReset()
|
|
83
|
+
readAccess.mockResolvedValue({ recipient: null, isSender: false, hasChannelThreadAccess: true, canRead: true })
|
|
76
84
|
})
|
|
77
85
|
|
|
78
86
|
it('sends the proposed text into the original thread, attributed to the approver', async () => {
|
|
@@ -178,6 +186,31 @@ describe('channel_discord.ai_reply_proposal.approve', () => {
|
|
|
178
186
|
expect(commandBus.execute).not.toHaveBeenCalled()
|
|
179
187
|
})
|
|
180
188
|
|
|
189
|
+
it('checks the approver can read the answered message before sending into its thread', async () => {
|
|
190
|
+
findOne.mockResolvedValueOnce(proposalRow()).mockResolvedValueOnce(inboundRow())
|
|
191
|
+
const { ctx, commandBus } = makeCtx()
|
|
192
|
+
|
|
193
|
+
await approveProposalCommand.execute(actionInput as never, ctx)
|
|
194
|
+
|
|
195
|
+
expect(readAccess).toHaveBeenCalledWith(
|
|
196
|
+
ctx,
|
|
197
|
+
{ tenantId: TENANT, organizationId: ORG, userId: OPERATOR },
|
|
198
|
+
expect.objectContaining({ id: INBOUND_ID }),
|
|
199
|
+
)
|
|
200
|
+
expect(commandBus.execute).toHaveBeenCalledTimes(1)
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
it('refuses to send when the approver can no longer read the answered message', async () => {
|
|
204
|
+
findOne.mockResolvedValueOnce(proposalRow()).mockResolvedValueOnce(inboundRow())
|
|
205
|
+
readAccess.mockResolvedValueOnce({ recipient: null, isSender: false, hasChannelThreadAccess: false, canRead: false })
|
|
206
|
+
const { ctx, commandBus } = makeCtx()
|
|
207
|
+
|
|
208
|
+
await expect(approveProposalCommand.execute(actionInput as never, ctx)).rejects.toThrow(
|
|
209
|
+
/cannot read the message this proposal answers/i,
|
|
210
|
+
)
|
|
211
|
+
expect(commandBus.execute).not.toHaveBeenCalled()
|
|
212
|
+
})
|
|
213
|
+
|
|
181
214
|
it('refuses an empty proposal rather than posting a blank Discord message', async () => {
|
|
182
215
|
findOne.mockResolvedValueOnce(proposalRow({ body: ' ' })).mockResolvedValueOnce(inboundRow())
|
|
183
216
|
const { ctx, commandBus } = makeCtx()
|
|
@@ -5,6 +5,7 @@ import { registerCommand } from '@open-mercato/shared/lib/commands'
|
|
|
5
5
|
import { createLogger } from '@open-mercato/shared/lib/logger'
|
|
6
6
|
import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
|
|
7
7
|
import { Message } from '@open-mercato/core/modules/messages/data/entities'
|
|
8
|
+
import { resolveMessageReadAccess } from '@open-mercato/core/modules/messages/lib/routeHelpers'
|
|
8
9
|
import {
|
|
9
10
|
CHANNEL_DISCORD_AI_PROPOSAL_APPROVE_COMMAND_ID,
|
|
10
11
|
CHANNEL_DISCORD_AI_PROPOSAL_DISMISS_COMMAND_ID,
|
|
@@ -98,6 +99,14 @@ const approveProposalCommand: CommandHandler<ProposalActionInput, ApproveProposa
|
|
|
98
99
|
if (!inbound) {
|
|
99
100
|
throw new Error('[internal] The message this proposal answers no longer exists')
|
|
100
101
|
}
|
|
102
|
+
// The send joins the answered message's thread, so the approving operator
|
|
103
|
+
// must be able to read that message — the same rule a compose naming it as
|
|
104
|
+
// its parent is held to. Access revoked after the proposal was filed refuses
|
|
105
|
+
// the send instead of posting into a conversation the operator lost.
|
|
106
|
+
const access = await resolveMessageReadAccess(ctx, scope, inbound)
|
|
107
|
+
if (!access.canRead) {
|
|
108
|
+
throw new Error('[internal] The approving operator cannot read the message this proposal answers')
|
|
109
|
+
}
|
|
101
110
|
|
|
102
111
|
const body = (proposal.body ?? '').trim()
|
|
103
112
|
if (!body) {
|