@open-mercato/channel-discord 0.7.1-develop.7130.1.fef2396fd8 → 0.7.1-develop.7135.1.19bf969756
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 +1 -1
- package/dist/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-011.spec.js +85 -0
- package/dist/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-011.spec.js.map +7 -0
- package/dist/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-012.spec.js +79 -0
- package/dist/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-012.spec.js.map +7 -0
- package/dist/modules/channel_discord/__integration__/meta.js +1 -1
- package/dist/modules/channel_discord/__integration__/meta.js.map +1 -1
- package/dist/modules/channel_discord/api/ai-auto-reply/channels/route.js +16 -8
- package/dist/modules/channel_discord/api/ai-auto-reply/channels/route.js.map +2 -2
- package/dist/modules/channel_discord/api/channels/[id]/ai-auto-reply/route.js +5 -0
- package/dist/modules/channel_discord/api/channels/[id]/ai-auto-reply/route.js.map +2 -2
- package/dist/modules/channel_discord/commands/ai-reply-proposal.js +7 -0
- package/dist/modules/channel_discord/commands/ai-reply-proposal.js.map +2 -2
- package/dist/modules/channel_discord/lib/adapter.js +3 -2
- package/dist/modules/channel_discord/lib/adapter.js.map +2 -2
- package/dist/modules/channel_discord/lib/channel-identity.js +7 -0
- package/dist/modules/channel_discord/lib/channel-identity.js.map +7 -0
- package/dist/modules/channel_discord/lib/failure-reason.js +35 -3
- package/dist/modules/channel_discord/lib/failure-reason.js.map +2 -2
- package/dist/modules/channel_discord/subscribers/ai-auto-reply.js +8 -0
- package/dist/modules/channel_discord/subscribers/ai-auto-reply.js.map +2 -2
- package/package.json +8 -8
- package/src/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-011.spec.ts +154 -0
- package/src/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-012.spec.ts +137 -0
- package/src/modules/channel_discord/__integration__/meta.ts +1 -1
- package/src/modules/channel_discord/api/ai-auto-reply/channels/__tests__/route.test.ts +48 -6
- package/src/modules/channel_discord/api/ai-auto-reply/channels/route.ts +39 -12
- package/src/modules/channel_discord/api/channels/[id]/ai-auto-reply/__tests__/route.interceptor.test.ts +149 -0
- package/src/modules/channel_discord/api/channels/[id]/ai-auto-reply/route.ts +5 -0
- package/src/modules/channel_discord/commands/__tests__/ai-reply-proposal.test.ts +30 -0
- package/src/modules/channel_discord/commands/ai-reply-proposal.ts +7 -0
- package/src/modules/channel_discord/lib/__tests__/failure-reason.test.ts +80 -0
- package/src/modules/channel_discord/lib/adapter.ts +3 -2
- package/src/modules/channel_discord/lib/channel-identity.ts +23 -0
- package/src/modules/channel_discord/lib/failure-reason.ts +89 -3
- package/src/modules/channel_discord/subscribers/__tests__/ai-auto-reply.test.ts +42 -0
- package/src/modules/channel_discord/subscribers/ai-auto-reply.ts +8 -0
package/.turbo/turbo-build.log
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
[build:channel-discord] found
|
|
1
|
+
[build:channel-discord] found 63 entry points
|
|
2
2
|
[build:channel-discord] built successfully
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { expect, test } from "@playwright/test";
|
|
2
|
+
import { apiRequest, getAuthToken } from "@open-mercato/core/helpers/integration/api";
|
|
3
|
+
import { getTokenContext, readJsonSafe } from "@open-mercato/core/helpers/integration/generalFixtures";
|
|
4
|
+
import { createUserFixture, deleteUserIfExists } from "@open-mercato/core/helpers/integration/authFixtures";
|
|
5
|
+
import {
|
|
6
|
+
deleteChannelIfExists,
|
|
7
|
+
isChannelSeedingAvailable,
|
|
8
|
+
seedConnectedChannel
|
|
9
|
+
} from "@open-mercato/core/helpers/integration/communicationChannelsFixtures";
|
|
10
|
+
const PANEL_PATH = "/api/channel_discord/ai-auto-reply/channels";
|
|
11
|
+
async function readPanel(request, token) {
|
|
12
|
+
const response = await apiRequest(request, "GET", PANEL_PATH, { token });
|
|
13
|
+
expect(response.status(), `GET ${PANEL_PATH} should return 200`).toBe(200);
|
|
14
|
+
const body = await readJsonSafe(response);
|
|
15
|
+
return body?.items ?? [];
|
|
16
|
+
}
|
|
17
|
+
test.describe("TC-CHANNEL-DISCORD-011: the AI auto-reply panel listing", () => {
|
|
18
|
+
test("lists the caller\u2019s own Discord channel and hides another operator\u2019s", async ({
|
|
19
|
+
request
|
|
20
|
+
}) => {
|
|
21
|
+
test.slow();
|
|
22
|
+
let token = null;
|
|
23
|
+
let otherToken = null;
|
|
24
|
+
let channelId = null;
|
|
25
|
+
let otherChannelId = null;
|
|
26
|
+
let otherUserId = null;
|
|
27
|
+
try {
|
|
28
|
+
token = await getAuthToken(request, "admin");
|
|
29
|
+
const seedingAvailable = await isChannelSeedingAvailable(request, token);
|
|
30
|
+
test.skip(
|
|
31
|
+
!seedingAvailable,
|
|
32
|
+
"OM_ENABLE_TEST_CHANNEL_SEEDING is not enabled in this environment; cannot connect a channel."
|
|
33
|
+
);
|
|
34
|
+
const stamp = Date.now();
|
|
35
|
+
const { organizationId } = getTokenContext(token);
|
|
36
|
+
const displayName = `TC-CHANNEL-DISCORD-011 ${stamp}`;
|
|
37
|
+
channelId = await seedConnectedChannel(request, token, {
|
|
38
|
+
displayName,
|
|
39
|
+
providerFlavor: "chat",
|
|
40
|
+
labelAsProviderKey: "discord"
|
|
41
|
+
});
|
|
42
|
+
const items = await readPanel(request, token);
|
|
43
|
+
const listed = items.find((item) => item.channelId === channelId);
|
|
44
|
+
expect(
|
|
45
|
+
listed,
|
|
46
|
+
"A connected Discord channel must appear in the panel that configures it \u2014 the panel is the only way into the AI auto-reply settings (#5602)"
|
|
47
|
+
).toBeTruthy();
|
|
48
|
+
expect(listed?.displayName).toBe(displayName);
|
|
49
|
+
expect(listed?.aiAutoReplyEnabled).toBe(false);
|
|
50
|
+
expect(listed?.aiAgentId).toBeNull();
|
|
51
|
+
const otherEmail = `tc-discord-011-${stamp}@integration.test`;
|
|
52
|
+
const otherPassword = "Secret123!";
|
|
53
|
+
otherUserId = await createUserFixture(request, token, {
|
|
54
|
+
email: otherEmail,
|
|
55
|
+
password: otherPassword,
|
|
56
|
+
organizationId,
|
|
57
|
+
roles: ["admin"],
|
|
58
|
+
name: `TC-CHANNEL-DISCORD-011 peer ${stamp}`
|
|
59
|
+
});
|
|
60
|
+
otherToken = await getAuthToken(request, otherEmail, otherPassword);
|
|
61
|
+
otherChannelId = await seedConnectedChannel(request, otherToken, {
|
|
62
|
+
displayName: `TC-CHANNEL-DISCORD-011 peer ${stamp}`,
|
|
63
|
+
providerFlavor: "chat",
|
|
64
|
+
labelAsProviderKey: "discord"
|
|
65
|
+
});
|
|
66
|
+
const ownerItems = await readPanel(request, token);
|
|
67
|
+
expect(
|
|
68
|
+
ownerItems.map((item) => item.channelId),
|
|
69
|
+
"Another operator\u2019s personal Discord channel must stay invisible, admin grant or not"
|
|
70
|
+
).not.toContain(otherChannelId);
|
|
71
|
+
const peerItems = await readPanel(request, otherToken);
|
|
72
|
+
expect(peerItems.map((item) => item.channelId)).toContain(otherChannelId);
|
|
73
|
+
expect(peerItems.map((item) => item.channelId)).not.toContain(channelId);
|
|
74
|
+
} finally {
|
|
75
|
+
await deleteChannelIfExists(request, otherToken, otherChannelId);
|
|
76
|
+
await deleteChannelIfExists(request, token, channelId);
|
|
77
|
+
await deleteUserIfExists(request, token, otherUserId);
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
test("refuses a caller whose session does not resolve", async ({ request }) => {
|
|
81
|
+
const response = await apiRequest(request, "GET", PANEL_PATH, { token: "not-a-session-token" });
|
|
82
|
+
expect([401, 403]).toContain(response.status());
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
//# sourceMappingURL=TC-CHANNEL-DISCORD-011.spec.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../../src/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-011.spec.ts"],
|
|
4
|
+
"sourcesContent": ["import { expect, test } from '@playwright/test'\nimport { apiRequest, getAuthToken } from '@open-mercato/core/helpers/integration/api'\nimport { getTokenContext, readJsonSafe } from '@open-mercato/core/helpers/integration/generalFixtures'\nimport { createUserFixture, deleteUserIfExists } from '@open-mercato/core/helpers/integration/authFixtures'\nimport {\n deleteChannelIfExists,\n isChannelSeedingAvailable,\n seedConnectedChannel,\n} from '@open-mercato/core/helpers/integration/communicationChannelsFixtures'\n\n/**\n * TC-CHANNEL-DISCORD-011 \u2014 the AI auto-reply panel lists the Discord channels\n * operators actually have.\n * Source: https://github.com/open-mercato/open-mercato/issues/5602\n *\n * The panel on `/backend/integrations/channel_discord` is the ONLY entry point\n * to the per-channel AI auto-reply settings \u2014 no row action on the channels\n * list, no menu item \u2014 so an empty panel is an unreachable feature, not a\n * cosmetic gap. It filtered `userId: null` on the assumption that a Discord bot\n * channel is tenant-scoped. Nothing the product exposes can create such a\n * channel: the connect widget posts to the per-user credentials route, which\n * writes `user_id = auth.sub`, and the tenant-wide route refuses Discord\n * outright because the adapter declares no `channelScope`. So the filter matched\n * nothing that can exist, and `GET /api/channel_discord/ai-auto-reply/channels`\n * returned `{\"items\":[]}` on a tenant with working Discord bots.\n *\n * The two halves this pins, because fixing one by breaking the other is the easy\n * mistake here:\n * 1. a per-user Discord channel \u2014 the only kind there is \u2014 appears for its\n * owner;\n * 2. it does NOT appear for anybody else, not even another admin. Personal\n * channel privacy is strict owner-only (v1), and widening a listing must\n * not be a way around it.\n *\n * Ceiling, stated rather than implied: a REAL Discord channel cannot be\n * connected in CI, because the adapter's `validateCredentials` performs a live\n * call against a bot token no test environment has. The fixture therefore\n * connects the network-free chat stub and relabels its `provider_key`. What that\n * does NOT stub is the thing that was broken \u2014 the row is a genuine\n * `communication_channels` row with a genuine `user_id`, produced by the real\n * connect command, and the route under test is the real one.\n *\n * Driven via the env-gated test-seed fixture (`OM_ENABLE_TEST_CHANNEL_SEEDING`);\n * skips when the gate is off.\n */\nconst PANEL_PATH = '/api/channel_discord/ai-auto-reply/channels'\n\ntype PanelItem = {\n channelId?: string\n displayName?: string\n aiAutoReplyEnabled?: boolean\n aiAgentId?: string | null\n}\n\nasync function readPanel(\n request: Parameters<typeof apiRequest>[0],\n token: string,\n): Promise<PanelItem[]> {\n const response = await apiRequest(request, 'GET', PANEL_PATH, { token })\n expect(response.status(), `GET ${PANEL_PATH} should return 200`).toBe(200)\n const body = await readJsonSafe<{ items?: PanelItem[] }>(response)\n return body?.items ?? []\n}\n\ntest.describe('TC-CHANNEL-DISCORD-011: the AI auto-reply panel listing', () => {\n test('lists the caller\u2019s own Discord channel and hides another operator\u2019s', async ({\n request,\n }) => {\n test.slow()\n let token: string | null = null\n let otherToken: string | null = null\n let channelId: string | null = null\n let otherChannelId: string | null = null\n let otherUserId: string | null = null\n\n try {\n token = await getAuthToken(request, 'admin')\n const seedingAvailable = await isChannelSeedingAvailable(request, token)\n test.skip(\n !seedingAvailable,\n 'OM_ENABLE_TEST_CHANNEL_SEEDING is not enabled in this environment; cannot connect a channel.',\n )\n\n const stamp = Date.now()\n const { organizationId } = getTokenContext(token)\n\n // `providerFlavor: 'chat'` gives the row the shape a real Discord channel\n // has (no email-ish credential, so `external_identifier` stays NULL); the\n // relabel is what makes the provider-scoped route see it at all.\n const displayName = `TC-CHANNEL-DISCORD-011 ${stamp}`\n channelId = await seedConnectedChannel(request, token, {\n displayName,\n providerFlavor: 'chat',\n labelAsProviderKey: 'discord',\n })\n\n const items = await readPanel(request, token)\n const listed = items.find((item) => item.channelId === channelId)\n\n expect(\n listed,\n 'A connected Discord channel must appear in the panel that configures it \u2014 ' +\n 'the panel is the only way into the AI auto-reply settings (#5602)',\n ).toBeTruthy()\n expect(listed?.displayName).toBe(displayName)\n // Default OFF, and the panel reports it rather than omitting the row.\n expect(listed?.aiAutoReplyEnabled).toBe(false)\n expect(listed?.aiAgentId).toBeNull()\n\n // A second operator, in the same admin role, with the same\n // `channel_discord.view` grant. Personal channels are owner-only in v1, so\n // no feature grant may reveal one \u2014 widening the listing must not have\n // opened that door.\n // Uppercase + digit + special: the default password policy\n // (`buildPasswordSchema`) requires all three, so a plainer literal would\n // fail at fixture creation rather than at the assertion under test.\n const otherEmail = `tc-discord-011-${stamp}@integration.test`\n const otherPassword = 'Secret123!'\n otherUserId = await createUserFixture(request, token, {\n email: otherEmail,\n password: otherPassword,\n organizationId,\n roles: ['admin'],\n name: `TC-CHANNEL-DISCORD-011 peer ${stamp}`,\n })\n otherToken = await getAuthToken(request, otherEmail, otherPassword)\n\n otherChannelId = await seedConnectedChannel(request, otherToken, {\n displayName: `TC-CHANNEL-DISCORD-011 peer ${stamp}`,\n providerFlavor: 'chat',\n labelAsProviderKey: 'discord',\n })\n\n const ownerItems = await readPanel(request, token)\n expect(\n ownerItems.map((item) => item.channelId),\n 'Another operator\u2019s personal Discord channel must stay invisible, admin grant or not',\n ).not.toContain(otherChannelId)\n\n const peerItems = await readPanel(request, otherToken)\n expect(peerItems.map((item) => item.channelId)).toContain(otherChannelId)\n expect(peerItems.map((item) => item.channelId)).not.toContain(channelId)\n } finally {\n await deleteChannelIfExists(request, otherToken, otherChannelId)\n await deleteChannelIfExists(request, token, channelId)\n await deleteUserIfExists(request, token, otherUserId)\n }\n })\n\n test('refuses a caller whose session does not resolve', async ({ request }) => {\n const response = await apiRequest(request, 'GET', PANEL_PATH, { token: 'not-a-session-token' })\n expect([401, 403]).toContain(response.status())\n })\n})\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { expect, test } from "@playwright/test";
|
|
2
|
+
import { apiRequest, getAuthToken } from "@open-mercato/core/helpers/integration/api";
|
|
3
|
+
import { getTokenScope, readJsonSafe } from "@open-mercato/core/helpers/integration/generalFixtures";
|
|
4
|
+
import {
|
|
5
|
+
deleteChannelIfExists,
|
|
6
|
+
ingestInboundChatMessage,
|
|
7
|
+
isChannelSeedingAvailable,
|
|
8
|
+
seedConnectedChannel
|
|
9
|
+
} from "@open-mercato/core/helpers/integration/communicationChannelsFixtures";
|
|
10
|
+
import { CHANNEL_DISCORD_AI_PROPOSAL_SOURCE_ENTITY_TYPE } from "../lib/ai-proposal-contract.js";
|
|
11
|
+
import {
|
|
12
|
+
CHANNEL_DISCORD_AI_PROPOSAL_APPROVE_ACTION_ID,
|
|
13
|
+
CHANNEL_DISCORD_AI_PROPOSAL_MESSAGE_TYPE
|
|
14
|
+
} from "../message-types.js";
|
|
15
|
+
test.describe("TC-CHANNEL-DISCORD-012: the AI reply send path", () => {
|
|
16
|
+
test("sends an approved reply on a channel whose sender has no address", async ({ request }) => {
|
|
17
|
+
test.slow();
|
|
18
|
+
let token = null;
|
|
19
|
+
let channelId = null;
|
|
20
|
+
try {
|
|
21
|
+
token = await getAuthToken(request, "admin");
|
|
22
|
+
const seedingAvailable = await isChannelSeedingAvailable(request, token);
|
|
23
|
+
test.skip(
|
|
24
|
+
!seedingAvailable,
|
|
25
|
+
"OM_ENABLE_TEST_CHANNEL_SEEDING is not enabled in this environment; cannot ingest."
|
|
26
|
+
);
|
|
27
|
+
const stamp = Date.now();
|
|
28
|
+
const { userId } = getTokenScope(token);
|
|
29
|
+
channelId = await seedConnectedChannel(request, token, {
|
|
30
|
+
displayName: `TC-CHANNEL-DISCORD-012 ${stamp}`,
|
|
31
|
+
providerFlavor: "chat"
|
|
32
|
+
});
|
|
33
|
+
const inbound = await ingestInboundChatMessage(request, token, {
|
|
34
|
+
channelId,
|
|
35
|
+
senderIdentifier: "1499156851487539260",
|
|
36
|
+
senderDisplayName: "Karol Kapsa",
|
|
37
|
+
body: "cze\u015B\u0107, czy mog\u0119 pisa\u0107 do was po polsku?",
|
|
38
|
+
externalMessageId: `tc-discord-012-${stamp}`,
|
|
39
|
+
externalConversationId: `tc-discord-012-conv-${stamp}`
|
|
40
|
+
});
|
|
41
|
+
expect(inbound.messageId, "the inbound message must land before a reply can be proposed").toBeTruthy();
|
|
42
|
+
const proposalResponse = await apiRequest(request, "POST", "/api/messages", {
|
|
43
|
+
token,
|
|
44
|
+
data: {
|
|
45
|
+
type: CHANNEL_DISCORD_AI_PROPOSAL_MESSAGE_TYPE,
|
|
46
|
+
visibility: "internal",
|
|
47
|
+
sourceEntityType: CHANNEL_DISCORD_AI_PROPOSAL_SOURCE_ENTITY_TYPE,
|
|
48
|
+
sourceEntityId: inbound.messageId,
|
|
49
|
+
recipients: [{ userId, type: "to" }],
|
|
50
|
+
subject: "Asked whether they may write in Polish.",
|
|
51
|
+
body: "Oczywi\u015Bcie, piszcie po polsku \u2014 odpowiemy w tym samym j\u0119zyku.",
|
|
52
|
+
bodyFormat: "markdown",
|
|
53
|
+
isDraft: false
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
expect(proposalResponse.status(), "POST /api/messages should file the proposal").toBe(201);
|
|
57
|
+
const proposal = await readJsonSafe(proposalResponse);
|
|
58
|
+
const proposalId = proposal?.id;
|
|
59
|
+
expect(proposalId, "the proposal response should include its message id").toBeTruthy();
|
|
60
|
+
const approveResponse = await apiRequest(
|
|
61
|
+
request,
|
|
62
|
+
"POST",
|
|
63
|
+
`/api/messages/${proposalId}/actions/${CHANNEL_DISCORD_AI_PROPOSAL_APPROVE_ACTION_ID}`,
|
|
64
|
+
{ token, data: {} }
|
|
65
|
+
);
|
|
66
|
+
expect(
|
|
67
|
+
approveResponse.status(),
|
|
68
|
+
"Approving a reply must not be rejected for a missing externalEmail \u2014 the sender is an opaque handle and has no address (#5601)"
|
|
69
|
+
).toBe(200);
|
|
70
|
+
const approved = await readJsonSafe(approveResponse);
|
|
71
|
+
expect(approved?.ok).toBe(true);
|
|
72
|
+
expect(approved?.actionId).toBe(CHANNEL_DISCORD_AI_PROPOSAL_APPROVE_ACTION_ID);
|
|
73
|
+
expect(approved?.result?.sentMessageId, "the approved reply should have been composed").toBeTruthy();
|
|
74
|
+
} finally {
|
|
75
|
+
await deleteChannelIfExists(request, token, channelId);
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
//# sourceMappingURL=TC-CHANNEL-DISCORD-012.spec.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../../src/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-012.spec.ts"],
|
|
4
|
+
"sourcesContent": ["import { expect, test } from '@playwright/test'\nimport { apiRequest, getAuthToken } from '@open-mercato/core/helpers/integration/api'\nimport { getTokenScope, readJsonSafe } from '@open-mercato/core/helpers/integration/generalFixtures'\nimport {\n deleteChannelIfExists,\n ingestInboundChatMessage,\n isChannelSeedingAvailable,\n seedConnectedChannel,\n} from '@open-mercato/core/helpers/integration/communicationChannelsFixtures'\nimport { CHANNEL_DISCORD_AI_PROPOSAL_SOURCE_ENTITY_TYPE } from '../lib/ai-proposal-contract'\nimport {\n CHANNEL_DISCORD_AI_PROPOSAL_APPROVE_ACTION_ID,\n CHANNEL_DISCORD_AI_PROPOSAL_MESSAGE_TYPE,\n} from '../message-types'\n\n/**\n * TC-CHANNEL-DISCORD-012 \u2014 the AI auto-reply SEND path completes for a sender\n * who has no email address.\n * Source: https://github.com/open-mercato/open-mercato/issues/5601\n *\n * The auto-reply's two tiers compose the same public reply through the same hub\n * command: the `easy` tier sends it unattended, the `complex` tier files a\n * proposal a human approves and the approve command sends it. Both omitted\n * `sourceChannelType`, `channelTypeRequiresExternalEmail` fails closed on an\n * absent one, and so the hub demanded an `externalEmail` from a Discord sender \u2014\n * a snowflake with no address. Every send threw `ZodError` and the subscriber\n * degraded to a no-op, which is why the feature shipped and never replied to\n * anyone.\n *\n * This spec drives the half of that path a test environment can reach without a\n * live model: an inbound message with no address anywhere, a proposal filed\n * against it, and a human approving the send. Everything after the approve is\n * production code \u2014 the approve command, `messages.messages.compose`, the\n * validator that rejected it, and the outbound bridge.\n *\n * Ceiling, stated rather than implied: the `easy` tier's unattended send starts\n * at an AI model call, which CI has no provider for. Its compose payload is\n * validated against the hub's REAL `composeMessageSchema` in\n * `subscribers/__tests__/ai-auto-reply.test.ts` and driven through the real\n * agent policy in `__tests__/ai-auto-reply.policy.integration.test.ts`; the two\n * tiers build the same payload, and this spec proves the hub accepts it over the\n * wire.\n *\n * Deliberately NOT stubbed: no address is invented anywhere. Inventing one is\n * exactly how the predecessor of TC-CHANNEL-DISCORD-003 stayed green through\n * three live defects (#4975).\n *\n * Driven via the env-gated test-seed fixture (`OM_ENABLE_TEST_CHANNEL_SEEDING`);\n * skips when the gate is off.\n */\ntest.describe('TC-CHANNEL-DISCORD-012: the AI reply send path', () => {\n test('sends an approved reply on a channel whose sender has no address', async ({ request }) => {\n test.slow()\n let token: string | null = null\n let channelId: string | null = null\n\n try {\n token = await getAuthToken(request, 'admin')\n const seedingAvailable = await isChannelSeedingAvailable(request, token)\n test.skip(\n !seedingAvailable,\n 'OM_ENABLE_TEST_CHANNEL_SEEDING is not enabled in this environment; cannot ingest.',\n )\n\n const stamp = Date.now()\n const { userId } = getTokenScope(token)\n\n // `providerFlavor: 'chat'` connects the non-email stub: senders carry an\n // opaque handle, `external_identifier` is NULL, and the fixture offers no\n // way to pass an address \u2014 the shape a real Discord channel has.\n channelId = await seedConnectedChannel(request, token, {\n displayName: `TC-CHANNEL-DISCORD-012 ${stamp}`,\n providerFlavor: 'chat',\n })\n\n const inbound = await ingestInboundChatMessage(request, token, {\n channelId,\n senderIdentifier: '1499156851487539260',\n senderDisplayName: 'Karol Kapsa',\n body: 'cze\u015B\u0107, czy mog\u0119 pisa\u0107 do was po polsku?',\n externalMessageId: `tc-discord-012-${stamp}`,\n externalConversationId: `tc-discord-012-conv-${stamp}`,\n })\n expect(inbound.messageId, 'the inbound message must land before a reply can be proposed').toBeTruthy()\n\n // The proposal the `complex` tier files: an internal note addressed to a\n // reviewer, carrying the drafted reply as its body and pointing at the\n // inbound message it answers. Nothing here can reach Discord on its own \u2014\n // it is `visibility: 'internal'`, which the outbound bridge never delivers.\n const proposalResponse = await apiRequest(request, 'POST', '/api/messages', {\n token,\n data: {\n type: CHANNEL_DISCORD_AI_PROPOSAL_MESSAGE_TYPE,\n visibility: 'internal',\n sourceEntityType: CHANNEL_DISCORD_AI_PROPOSAL_SOURCE_ENTITY_TYPE,\n sourceEntityId: inbound.messageId,\n recipients: [{ userId, type: 'to' }],\n subject: 'Asked whether they may write in Polish.',\n body: 'Oczywi\u015Bcie, piszcie po polsku \u2014 odpowiemy w tym samym j\u0119zyku.',\n bodyFormat: 'markdown',\n isDraft: false,\n },\n })\n expect(proposalResponse.status(), 'POST /api/messages should file the proposal').toBe(201)\n const proposal = await readJsonSafe<{ id?: string }>(proposalResponse)\n const proposalId = proposal?.id\n expect(proposalId, 'the proposal response should include its message id').toBeTruthy()\n\n // The approve action, dispatched the way the inbox dispatches it. This is\n // the call that used to fail with\n // `externalEmail is required when visibility is public`.\n const approveResponse = await apiRequest(\n request,\n 'POST',\n `/api/messages/${proposalId}/actions/${CHANNEL_DISCORD_AI_PROPOSAL_APPROVE_ACTION_ID}`,\n { token, data: {} },\n )\n\n expect(\n approveResponse.status(),\n 'Approving a reply must not be rejected for a missing externalEmail \u2014 ' +\n 'the sender is an opaque handle and has no address (#5601)',\n ).toBe(200)\n\n const approved = await readJsonSafe<{\n ok?: boolean\n actionId?: string\n result?: { ok?: boolean; sentMessageId?: string }\n }>(approveResponse)\n expect(approved?.ok).toBe(true)\n expect(approved?.actionId).toBe(CHANNEL_DISCORD_AI_PROPOSAL_APPROVE_ACTION_ID)\n expect(approved?.result?.sentMessageId, 'the approved reply should have been composed').toBeTruthy()\n } finally {\n await deleteChannelIfExists(request, token, channelId)\n }\n })\n})\n"],
|
|
5
|
+
"mappings": "AAAA,SAAS,QAAQ,YAAY;AAC7B,SAAS,YAAY,oBAAoB;AACzC,SAAS,eAAe,oBAAoB;AAC5C;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AACP,SAAS,sDAAsD;AAC/D;AAAA,EACE;AAAA,EACA;AAAA,OACK;AAqCP,KAAK,SAAS,kDAAkD,MAAM;AACpE,OAAK,oEAAoE,OAAO,EAAE,QAAQ,MAAM;AAC9F,SAAK,KAAK;AACV,QAAI,QAAuB;AAC3B,QAAI,YAA2B;AAE/B,QAAI;AACF,cAAQ,MAAM,aAAa,SAAS,OAAO;AAC3C,YAAM,mBAAmB,MAAM,0BAA0B,SAAS,KAAK;AACvE,WAAK;AAAA,QACH,CAAC;AAAA,QACD;AAAA,MACF;AAEA,YAAM,QAAQ,KAAK,IAAI;AACvB,YAAM,EAAE,OAAO,IAAI,cAAc,KAAK;AAKtC,kBAAY,MAAM,qBAAqB,SAAS,OAAO;AAAA,QACrD,aAAa,0BAA0B,KAAK;AAAA,QAC5C,gBAAgB;AAAA,MAClB,CAAC;AAED,YAAM,UAAU,MAAM,yBAAyB,SAAS,OAAO;AAAA,QAC7D;AAAA,QACA,kBAAkB;AAAA,QAClB,mBAAmB;AAAA,QACnB,MAAM;AAAA,QACN,mBAAmB,kBAAkB,KAAK;AAAA,QAC1C,wBAAwB,uBAAuB,KAAK;AAAA,MACtD,CAAC;AACD,aAAO,QAAQ,WAAW,8DAA8D,EAAE,WAAW;AAMrG,YAAM,mBAAmB,MAAM,WAAW,SAAS,QAAQ,iBAAiB;AAAA,QAC1E;AAAA,QACA,MAAM;AAAA,UACJ,MAAM;AAAA,UACN,YAAY;AAAA,UACZ,kBAAkB;AAAA,UAClB,gBAAgB,QAAQ;AAAA,UACxB,YAAY,CAAC,EAAE,QAAQ,MAAM,KAAK,CAAC;AAAA,UACnC,SAAS;AAAA,UACT,MAAM;AAAA,UACN,YAAY;AAAA,UACZ,SAAS;AAAA,QACX;AAAA,MACF,CAAC;AACD,aAAO,iBAAiB,OAAO,GAAG,6CAA6C,EAAE,KAAK,GAAG;AACzF,YAAM,WAAW,MAAM,aAA8B,gBAAgB;AACrE,YAAM,aAAa,UAAU;AAC7B,aAAO,YAAY,qDAAqD,EAAE,WAAW;AAKrF,YAAM,kBAAkB,MAAM;AAAA,QAC5B;AAAA,QACA;AAAA,QACA,iBAAiB,UAAU,YAAY,6CAA6C;AAAA,QACpF,EAAE,OAAO,MAAM,CAAC,EAAE;AAAA,MACpB;AAEA;AAAA,QACE,gBAAgB,OAAO;AAAA,QACvB;AAAA,MAEF,EAAE,KAAK,GAAG;AAEV,YAAM,WAAW,MAAM,aAIpB,eAAe;AAClB,aAAO,UAAU,EAAE,EAAE,KAAK,IAAI;AAC9B,aAAO,UAAU,QAAQ,EAAE,KAAK,6CAA6C;AAC7E,aAAO,UAAU,QAAQ,eAAe,8CAA8C,EAAE,WAAW;AAAA,IACrG,UAAE;AACA,YAAM,sBAAsB,SAAS,OAAO,SAAS;AAAA,IACvD;AAAA,EACF,CAAC;AACH,CAAC;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
const integrationMeta = {
|
|
2
|
-
description: "Discord channel provider integration coverage (TC-CHANNEL-DISCORD-001..008)",
|
|
2
|
+
description: "Discord channel provider integration coverage (TC-CHANNEL-DISCORD-001..008, 011..012)",
|
|
3
3
|
dependsOnModules: ["communication_channels", "channel_discord"]
|
|
4
4
|
};
|
|
5
5
|
var meta_default = integrationMeta;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../src/modules/channel_discord/__integration__/meta.ts"],
|
|
4
|
-
"sourcesContent": ["export const integrationMeta = {\n description: 'Discord channel provider integration coverage (TC-CHANNEL-DISCORD-001..008)',\n dependsOnModules: ['communication_channels', 'channel_discord'],\n}\n\nexport default integrationMeta\n"],
|
|
4
|
+
"sourcesContent": ["export const integrationMeta = {\n description: 'Discord channel provider integration coverage (TC-CHANNEL-DISCORD-001..008, 011..012)',\n dependsOnModules: ['communication_channels', 'channel_discord'],\n}\n\nexport default integrationMeta\n"],
|
|
5
5
|
"mappings": "AAAO,MAAM,kBAAkB;AAAA,EAC7B,aAAa;AAAA,EACb,kBAAkB,CAAC,0BAA0B,iBAAiB;AAChE;AAEA,IAAO,eAAQ;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -5,8 +5,12 @@ import { findWithDecryption } from "@open-mercato/shared/lib/encryption/find";
|
|
|
5
5
|
import { resolveOrganizationScopeForRequest } from "@open-mercato/core/modules/directory/utils/organizationScope";
|
|
6
6
|
import { resolveOrganizationScopeFilter } from "@open-mercato/core/modules/directory/utils/organizationScopeFilter";
|
|
7
7
|
import { CommunicationChannel } from "@open-mercato/core/modules/communication_channels/data/entities";
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
channelOrgScopeWhereFromFilter,
|
|
10
|
+
channelOwnerScopeWhere
|
|
11
|
+
} from "@open-mercato/core/modules/communication_channels/lib/access-control";
|
|
9
12
|
import { CHANNEL_DISCORD_VIEW_FEATURE } from "../../../lib/ai-features.js";
|
|
13
|
+
import { DISCORD_PROVIDER_KEY } from "../../../lib/channel-identity.js";
|
|
10
14
|
import { discordChannelStateSchema } from "../../../lib/credentials.js";
|
|
11
15
|
const metadata = {
|
|
12
16
|
path: "/channel_discord/ai-auto-reply/channels",
|
|
@@ -25,16 +29,20 @@ async function GET(req) {
|
|
|
25
29
|
const em = container.resolve("em").fork();
|
|
26
30
|
const scope = await resolveOrganizationScopeForRequest({ container, auth, request: req });
|
|
27
31
|
const orgFilter = resolveOrganizationScopeFilter(scope, auth);
|
|
32
|
+
const scopeClauses = [
|
|
33
|
+
channelOwnerScopeWhere(auth.sub),
|
|
34
|
+
channelOrgScopeWhereFromFilter(orgFilter)
|
|
35
|
+
].filter((clause) => Object.keys(clause).length > 0);
|
|
36
|
+
const where = {
|
|
37
|
+
tenantId: auth.tenantId,
|
|
38
|
+
providerKey: DISCORD_PROVIDER_KEY,
|
|
39
|
+
$and: scopeClauses,
|
|
40
|
+
deletedAt: null
|
|
41
|
+
};
|
|
28
42
|
const channels = await findWithDecryption(
|
|
29
43
|
em,
|
|
30
44
|
CommunicationChannel,
|
|
31
|
-
|
|
32
|
-
tenantId: auth.tenantId,
|
|
33
|
-
providerKey: "discord",
|
|
34
|
-
...channelOrgScopeWhereFromFilter(orgFilter),
|
|
35
|
-
userId: null,
|
|
36
|
-
deletedAt: null
|
|
37
|
-
},
|
|
45
|
+
where,
|
|
38
46
|
{ limit: MAX_CHANNELS + 1, orderBy: { createdAt: "desc" } },
|
|
39
47
|
{ tenantId: auth.tenantId, organizationId: orgFilter.rbacOrganizationId }
|
|
40
48
|
);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../../../src/modules/channel_discord/api/ai-auto-reply/channels/route.ts"],
|
|
4
|
-
"sourcesContent": ["import { NextResponse } from 'next/server'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { findWithDecryption } 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 {
|
|
5
|
-
"mappings": "AAAA,SAAS,oBAAoB;
|
|
4
|
+
"sourcesContent": ["import { NextResponse } from 'next/server'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport type { FilterQuery } from '@mikro-orm/core'\nimport { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { findWithDecryption } 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 channelOrgScopeWhereFromFilter,\n channelOwnerScopeWhere,\n} from '@open-mercato/core/modules/communication_channels/lib/access-control'\nimport { CHANNEL_DISCORD_VIEW_FEATURE } from '../../../lib/ai-features'\nimport { DISCORD_PROVIDER_KEY } from '../../../lib/channel-identity'\nimport { discordChannelStateSchema } from '../../../lib/credentials'\n\n/**\n * Every Discord channel's AI auto-reply state in one call.\n *\n * The integration detail panel needs two booleans and an agent id per channel.\n * Asking the per-channel settings route for them meant one request per channel\n * *and* one full agent-registry load per request \u2014 41 requests and 40 registry\n * loads to render 40 rows on a page operators land on. Nothing about that panel\n * needs the agent directory, so this route does not build one.\n *\n * The path is pinned in `metadata` rather than inferred from the file location,\n * the way `api/interactions/route.ts` pins its own: the sibling settings route\n * lives under `channels/[id]/`, and a collection endpoint that reads as a channel\n * id (`/channels/ai-auto-reply`) is a URL waiting to be mistaken for one.\n */\nexport const metadata = {\n path: '/channel_discord/ai-auto-reply/channels',\n GET: {\n requireAuth: true,\n requireFeatures: [CHANNEL_DISCORD_VIEW_FEATURE],\n },\n}\n\n/**\n * The panel lists a handful of bot channels; the cap is a guard against an\n * unbounded scan, not a paging contract. `truncated` tells the client when it is\n * looking at a partial list rather than letting it silently under-report.\n */\nconst MAX_CHANNELS = 200\n\nexport async function GET(req: Request): Promise<Response> {\n const auth = await getAuthFromRequest(req)\n if (!auth?.sub || !auth?.tenantId) {\n return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })\n }\n\n const container = await createRequestContainer()\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 // Same org scoping the hub's own channel list uses: a channel connected while\n // no organization was selected is stored with `organization_id IS NULL`, so an\n // equality filter on the caller's selected organization would hide exactly the\n // rows this panel is about (#5012).\n //\n // Ownership is scoped the way the hub's per-channel guards do rather than by a\n // hardcoded `userId: null` (#5602). Every route the product exposes for\n // connecting a Discord bot writes `user_id = auth.sub` \u2014 the connect widget\n // posts to the per-user credentials route, and the tenant-wide route refuses\n // Discord outright \u2014 so a shared-only filter matched nothing that can exist\n // and the panel, the feature's only entry point, was always empty. The clause\n // mirrors `assertCanAccessChannel` exactly, so widening the listing does not\n // widen access: another operator's personal channel stays invisible, and the\n // per-channel settings route still runs its own `manage` guard before anything\n // can be armed.\n //\n // Both fragments can carry an `$or`, so they are nested under `$and` \u2014 spread\n // side by side, the second would silently overwrite the first. The ownership\n // clause is always present; the org fragment drops out for an unrestricted\n // caller, so `$and` never ends up empty.\n const scopeClauses = [\n channelOwnerScopeWhere(auth.sub as string),\n channelOrgScopeWhereFromFilter(orgFilter),\n ].filter((clause) => Object.keys(clause).length > 0) as FilterQuery<CommunicationChannel>[]\n\n const where: FilterQuery<CommunicationChannel> = {\n tenantId: auth.tenantId as string,\n providerKey: DISCORD_PROVIDER_KEY,\n $and: scopeClauses,\n deletedAt: null,\n }\n\n const channels = await findWithDecryption(\n em,\n CommunicationChannel,\n where,\n { limit: MAX_CHANNELS + 1, orderBy: { createdAt: 'desc' } },\n { tenantId: auth.tenantId as string, organizationId: orgFilter.rbacOrganizationId },\n )\n\n const truncated = channels.length > MAX_CHANNELS\n const items = (truncated ? channels.slice(0, MAX_CHANNELS) : channels).map((channel) => {\n const state = discordChannelStateSchema.safeParse(channel.channelState ?? {})\n return {\n channelId: channel.id,\n displayName: channel.displayName,\n aiAutoReplyEnabled: state.success ? Boolean(state.data.aiAutoReplyEnabled) : false,\n aiAgentId: (state.success ? state.data.aiAgentId : undefined) ?? null,\n aiAutoReplyLastError: (state.success ? state.data.aiAutoReplyLastError : undefined) ?? null,\n aiAutoReplyLastErrorAt: (state.success ? state.data.aiAutoReplyLastErrorAt : undefined) ?? null,\n }\n })\n\n return NextResponse.json({ items, truncated })\n}\n\nexport const openApi = {\n tags: ['CommunicationChannels'],\n methods: {\n GET: {\n summary: 'List the AI auto-reply state of every Discord channel in scope',\n tags: ['CommunicationChannels'],\n responses: [\n { status: 200, description: 'One row per Discord channel the caller may see' },\n { status: 401, description: 'Unauthorized' },\n ],\n },\n },\n}\n"],
|
|
5
|
+
"mappings": "AAAA,SAAS,oBAAoB;AAG7B,SAAS,0BAA0B;AACnC,SAAS,8BAA8B;AACvC,SAAS,0BAA0B;AACnC,SAAS,0CAA0C;AACnD,SAAS,sCAAsC;AAC/C,SAAS,4BAA4B;AACrC;AAAA,EACE;AAAA,EACA;AAAA,OACK;AACP,SAAS,oCAAoC;AAC7C,SAAS,4BAA4B;AACrC,SAAS,iCAAiC;AAgBnC,MAAM,WAAW;AAAA,EACtB,MAAM;AAAA,EACN,KAAK;AAAA,IACH,aAAa;AAAA,IACb,iBAAiB,CAAC,4BAA4B;AAAA,EAChD;AACF;AAOA,MAAM,eAAe;AAErB,eAAsB,IAAI,KAAiC;AACzD,QAAM,OAAO,MAAM,mBAAmB,GAAG;AACzC,MAAI,CAAC,MAAM,OAAO,CAAC,MAAM,UAAU;AACjC,WAAO,aAAa,KAAK,EAAE,OAAO,eAAe,GAAG,EAAE,QAAQ,IAAI,CAAC;AAAA,EACrE;AAEA,QAAM,YAAY,MAAM,uBAAuB;AAC/C,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;AAsBrE,QAAM,eAAe;AAAA,IACnB,uBAAuB,KAAK,GAAa;AAAA,IACzC,+BAA+B,SAAS;AAAA,EAC1C,EAAE,OAAO,CAAC,WAAW,OAAO,KAAK,MAAM,EAAE,SAAS,CAAC;AAEnD,QAAM,QAA2C;AAAA,IAC/C,UAAU,KAAK;AAAA,IACf,aAAa;AAAA,IACb,MAAM;AAAA,IACN,WAAW;AAAA,EACb;AAEA,QAAM,WAAW,MAAM;AAAA,IACrB;AAAA,IACA;AAAA,IACA;AAAA,IACA,EAAE,OAAO,eAAe,GAAG,SAAS,EAAE,WAAW,OAAO,EAAE;AAAA,IAC1D,EAAE,UAAU,KAAK,UAAoB,gBAAgB,UAAU,mBAAmB;AAAA,EACpF;AAEA,QAAM,YAAY,SAAS,SAAS;AACpC,QAAM,SAAS,YAAY,SAAS,MAAM,GAAG,YAAY,IAAI,UAAU,IAAI,CAAC,YAAY;AACtF,UAAM,QAAQ,0BAA0B,UAAU,QAAQ,gBAAgB,CAAC,CAAC;AAC5E,WAAO;AAAA,MACL,WAAW,QAAQ;AAAA,MACnB,aAAa,QAAQ;AAAA,MACrB,oBAAoB,MAAM,UAAU,QAAQ,MAAM,KAAK,kBAAkB,IAAI;AAAA,MAC7E,YAAY,MAAM,UAAU,MAAM,KAAK,YAAY,WAAc;AAAA,MACjE,uBAAuB,MAAM,UAAU,MAAM,KAAK,uBAAuB,WAAc;AAAA,MACvF,yBAAyB,MAAM,UAAU,MAAM,KAAK,yBAAyB,WAAc;AAAA,IAC7F;AAAA,EACF,CAAC;AAED,SAAO,aAAa,KAAK,EAAE,OAAO,UAAU,CAAC;AAC/C;AAEO,MAAM,UAAU;AAAA,EACrB,MAAM,CAAC,uBAAuB;AAAA,EAC9B,SAAS;AAAA,IACP,KAAK;AAAA,MACH,SAAS;AAAA,MACT,MAAM,CAAC,uBAAuB;AAAA,MAC9B,WAAW;AAAA,QACT,EAAE,QAAQ,KAAK,aAAa,iDAAiD;AAAA,QAC7E,EAAE,QAAQ,KAAK,aAAa,eAAe;AAAA,MAC7C;AAAA,IACF;AAAA,EACF;AACF;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -3,6 +3,7 @@ import { z } from "zod";
|
|
|
3
3
|
import { getAuthFromRequest } from "@open-mercato/shared/lib/auth/server";
|
|
4
4
|
import { createRequestContainer } from "@open-mercato/shared/lib/di/container";
|
|
5
5
|
import { isCrudHttpError } from "@open-mercato/shared/lib/crud/errors";
|
|
6
|
+
import { getCommandInterceptorHttpRejection } from "@open-mercato/shared/lib/commands/errors";
|
|
6
7
|
import { validateRouteMutationGuard } from "@open-mercato/core/modules/communication_channels/lib/route-mutation-guard";
|
|
7
8
|
import { CHANNEL_DISCORD_AUTO_REPLY_AGENT_ID } from "../../../../ai-agents.js";
|
|
8
9
|
import {
|
|
@@ -168,6 +169,10 @@ async function PUT(req, context) {
|
|
|
168
169
|
if (isCrudHttpError(err)) {
|
|
169
170
|
return NextResponse.json(err.body, { status: err.status });
|
|
170
171
|
}
|
|
172
|
+
const interceptorRejection = getCommandInterceptorHttpRejection(err);
|
|
173
|
+
if (interceptorRejection) {
|
|
174
|
+
return NextResponse.json(interceptorRejection.body, { status: interceptorRejection.status });
|
|
175
|
+
}
|
|
171
176
|
throw err;
|
|
172
177
|
}
|
|
173
178
|
if (result.status === "not_found") {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../../../../src/modules/channel_discord/api/channels/%5Bid%5D/ai-auto-reply/route.ts"],
|
|
4
|
-
"sourcesContent": ["import { NextResponse } from 'next/server'\nimport { z } from 'zod'\nimport type { AwilixContainer } from 'awilix'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport type { CommandBus } from '@open-mercato/shared/lib/commands'\nimport { isCrudHttpError } from '@open-mercato/shared/lib/crud/errors'\nimport { validateRouteMutationGuard } from '@open-mercato/core/modules/communication_channels/lib/route-mutation-guard'\nimport { CHANNEL_DISCORD_AUTO_REPLY_AGENT_ID } from '../../../../ai-agents'\nimport {\n CHANNEL_DISCORD_CHANNEL_RESOURCE_KIND,\n CHANNEL_DISCORD_UPDATE_AI_AUTO_REPLY_COMMAND_ID,\n discordAiAutoReplySettingsSchema,\n type UpdateAiAutoReplyInput,\n type UpdateAiAutoReplyResult,\n} from '../../../../commands/update-ai-auto-reply'\nimport {\n findDiscordEligibleAgent,\n listDiscordEligibleAgents,\n missingAgentFeatures,\n type AgentInvokingPrincipal,\n} from '../../../../lib/ai-agent-directory'\nimport { CHANNEL_DISCORD_CONFIGURE_FEATURE, CHANNEL_DISCORD_VIEW_FEATURE } from '../../../../lib/ai-features'\nimport { resolveDiscordAiPrincipal } from '../../../../lib/ai-service-principal'\nimport { loadDiscordChannelForRequest } from '../../../../lib/channel-access'\nimport { discordChannelStateSchema } from '../../../../lib/credentials'\n\nexport const metadata = {\n GET: {\n requireAuth: true,\n requireFeatures: [CHANNEL_DISCORD_VIEW_FEATURE],\n },\n PUT: {\n requireAuth: true,\n requireFeatures: [CHANNEL_DISCORD_CONFIGURE_FEATURE],\n },\n}\n\ntype RouteContext = {\n params: Promise<{ id: string }> | { id: string }\n}\n\nfunction fieldError(field: string, message: string, extra?: Record<string, unknown>): Response {\n return NextResponse.json(\n { error: message, fieldErrors: { [field]: message }, ...extra },\n { status: 400 },\n )\n}\n\n/**\n * The identity the auto-reply subscriber will run the chosen agent under, so both\n * methods can answer \"would the runtime accept this?\" with the same grants the\n * subscriber will present \u2014 the tenant's channel-bot user when it exists, the\n * provider service principal otherwise.\n */\nasync function loadInvokingPrincipal(\n container: AwilixContainer,\n scope: { tenantId: string; organizationId: string | null },\n): Promise<AgentInvokingPrincipal> {\n const em = (container.resolve('em') as EntityManager).fork()\n return resolveDiscordAiPrincipal({\n em,\n resolver: { resolve: <T,>(name: string): T => container.resolve(name) as T },\n scope,\n })\n}\n\n/**\n * Read a Discord channel's AI auto-reply settings plus everything the settings\n * form needs to render itself: the eligible agents, whether the optional AI peer\n * is installed at all, and the channel's `updatedAt` so the form can attach the\n * optimistic-lock header on save.\n */\nexport async function GET(req: Request, context: RouteContext): Promise<Response> {\n const { id } = await context.params\n if (!z.string().uuid().safeParse(id).success) {\n return NextResponse.json({ error: 'Invalid channel id' }, { status: 400 })\n }\n\n const auth = await getAuthFromRequest(req)\n if (!auth?.sub || !auth?.tenantId) {\n return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })\n }\n\n const container = await createRequestContainer()\n const access = await loadDiscordChannelForRequest({ container, req, auth, channelId: id, mode: 'read' })\n if ('response' in access) return access.response\n const { channel } = access\n\n const state = discordChannelStateSchema.safeParse(channel.channelState ?? {})\n const directory = await listDiscordEligibleAgents()\n\n // Every offered agent carries whether the auto-reply principal could actually\n // invoke it. Without this the picker offers agents from other modules that the\n // runtime will refuse, and the refusal only ever happens later in a background\n // subscriber \u2014 so the operator sees \"Auto-reply on\" and silence.\n const principal = directory.available\n ? await loadInvokingPrincipal(container, {\n tenantId: auth.tenantId as string,\n organizationId: access.rbacOrganizationId,\n })\n : null\n const agents = directory.available && principal\n ? directory.agents.map((agent) => {\n const missingFeatures = missingAgentFeatures(agent.requiredFeatures, principal)\n return { ...agent, invocable: missingFeatures.length === 0, missingFeatures }\n })\n : []\n\n return NextResponse.json({\n id: channel.id,\n channelId: channel.id,\n displayName: channel.displayName,\n updatedAt: channel.updatedAt ? channel.updatedAt.toISOString() : null,\n aiAutoReplyEnabled: state.success ? Boolean(state.data.aiAutoReplyEnabled) : false,\n aiAgentId: (state.success ? state.data.aiAgentId : undefined) ?? null,\n // Why the last attempt produced nothing, so an armed channel that answers\n // nothing says so instead of looking healthy.\n aiAutoReplyLastError: (state.success ? state.data.aiAutoReplyLastError : undefined) ?? null,\n aiAutoReplyLastErrorAt: (state.success ? state.data.aiAutoReplyLastErrorAt : undefined) ?? null,\n defaultAgentId: CHANNEL_DISCORD_AUTO_REPLY_AGENT_ID,\n aiAvailable: directory.available,\n agents,\n })\n}\n\n/**\n * Write a Discord channel's AI auto-reply settings \u2014 the configuration path the\n * subscriber was missing (issue #4778).\n *\n * The route is deliberately strict about *enabling*: it refuses to arm a channel\n * whose AI peer is absent, one pointed at an agent the runtime would reject on\n * shape, and one pointed at an agent the auto-reply principal is not authorized\n * to invoke. All three would store a setting that can only fail later, inside a\n * background subscriber where nobody sees the error \u2014 which is precisely how the\n * feature ended up dormant in the first place. The three checks mirror, in order,\n * what `runAiAgentObject` \u2192 `checkAgentPolicy` will ask at run time.\n */\nexport async function PUT(req: Request, context: RouteContext): Promise<Response> {\n const { id } = await context.params\n if (!z.string().uuid().safeParse(id).success) {\n return NextResponse.json({ error: 'Invalid channel id' }, { status: 400 })\n }\n\n const auth = await getAuthFromRequest(req)\n if (!auth?.sub || !auth?.tenantId) {\n return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })\n }\n const organizationId = (auth as { orgId?: string | null }).orgId ?? null\n\n const rawBody = await req.json().catch(() => null)\n if (rawBody == null || typeof rawBody !== 'object') {\n return NextResponse.json({ error: 'Request body must be a JSON object' }, { status: 400 })\n }\n\n const parsed = discordAiAutoReplySettingsSchema.safeParse(rawBody)\n if (!parsed.success) {\n const issue = parsed.error.issues[0]\n const field = issue?.path.join('.') || 'aiAutoReplyEnabled'\n return fieldError(field, issue?.message ?? 'Invalid AI auto-reply settings')\n }\n\n const container = await createRequestContainer()\n // Authorize before the guard so a caller who may not touch this channel gets a\n // masked 404 rather than a guard verdict about a record they cannot see. It\n // also comes before the arming checks below, which need the channel's resolved\n // organization to build the principal they judge the agent against.\n const access = await loadDiscordChannelForRequest({ container, req, auth, channelId: id, mode: 'manage' })\n if ('response' in access) return access.response\n\n if (parsed.data.aiAutoReplyEnabled) {\n const agentId = parsed.data.aiAgentId as string\n const agent = await findDiscordEligibleAgent(agentId)\n if (!agent) {\n // No eligible agent under this id: either the optional AI peer is absent, or\n // the agent is not object-mode and `runAiAgentObject` would reject it.\n const directory = await listDiscordEligibleAgents()\n return directory.available\n ? fieldError('aiAgentId', 'channel_discord.aiAutoReply.errors.agentNotEligible')\n : fieldError('aiAutoReplyEnabled', 'channel_discord.aiAutoReply.errors.aiUnavailable')\n }\n\n // Shape is not authorization. `checkAgentPolicy` enforces the agent's\n // `requiredFeatures` against the auto-reply principal at run time, so arming a\n // channel against an agent that principal cannot invoke stores a setting that\n // can only fail later \u2014 inside a background subscriber, where the operator\n // never sees it. Refuse here, naming the grants the tenant's channel-bot user\n // is missing, rather than accepting a channel that would answer nothing.\n const principal = await loadInvokingPrincipal(container, {\n tenantId: auth.tenantId as string,\n organizationId: access.rbacOrganizationId,\n })\n const missingFeatures = missingAgentFeatures(agent.requiredFeatures, principal)\n if (missingFeatures.length > 0) {\n return fieldError(\n 'aiAgentId',\n 'channel_discord.aiAutoReply.errors.agentFeaturesMissing',\n { missingFeatures },\n )\n }\n }\n\n const guard = await validateRouteMutationGuard({\n container,\n req,\n auth,\n input: {\n resourceKind: CHANNEL_DISCORD_CHANNEL_RESOURCE_KIND,\n resourceId: id,\n operation: 'update',\n mutationPayload: { ...parsed.data },\n },\n })\n if ('response' in guard) return guard.response\n\n const commandBus = container.resolve('commandBus') as CommandBus\n const input: UpdateAiAutoReplyInput = {\n channelId: id,\n settings: parsed.data,\n scope: {\n tenantId: auth.tenantId as string,\n organizationId: access.rbacOrganizationId,\n organizationIds: access.organizationIds,\n },\n }\n\n let result: UpdateAiAutoReplyResult\n try {\n const executed = await commandBus.execute<UpdateAiAutoReplyInput, UpdateAiAutoReplyResult>(\n CHANNEL_DISCORD_UPDATE_AI_AUTO_REPLY_COMMAND_ID,\n {\n input,\n ctx: {\n container,\n auth: auth as never,\n organizationScope: null,\n selectedOrganizationId: organizationId,\n organizationIds: organizationId ? [organizationId] : null,\n request: req,\n },\n },\n )\n result = executed.result\n } catch (err) {\n // The optimistic-lock guard throws the shared structured 409 so the form's\n // conflict bar renders the same way it does anywhere else in the product.\n if (isCrudHttpError(err)) {\n return NextResponse.json(err.body, { status: err.status })\n }\n throw err\n }\n\n if (result.status === 'not_found') {\n return NextResponse.json({ error: 'Channel not found' }, { status: 404 })\n }\n\n await guard.afterSuccess()\n return NextResponse.json(\n {\n channelId: result.channelId,\n aiAutoReplyEnabled: result.aiAutoReplyEnabled,\n aiAgentId: result.aiAgentId,\n updatedAt: result.updatedAt,\n },\n { status: 200 },\n )\n}\n\nexport const openApi = {\n tags: ['CommunicationChannels'],\n methods: {\n GET: {\n summary: \"Read a Discord channel's AI auto-reply configuration\",\n tags: ['CommunicationChannels'],\n responses: [\n { status: 200, description: 'Current settings plus the agents this channel may be pointed at' },\n { status: 400, description: 'Invalid channel id' },\n { status: 401, description: 'Unauthorized' },\n { status: 404, description: 'Channel not found, not a Discord channel in scope, or not the caller\u2019s to see' },\n ],\n },\n PUT: {\n summary: \"Update a Discord channel's AI auto-reply configuration\",\n tags: ['CommunicationChannels'],\n responses: [\n { status: 200, description: 'Settings stored' },\n {\n status: 400,\n description:\n 'Invalid payload, AI module absent, agent not eligible, or the auto-reply principal lacks the agent\u2019s required features',\n },\n { status: 401, description: 'Unauthorized' },\n { status: 404, description: 'Channel not found, or not a Discord channel in this scope' },\n { status: 409, description: 'The channel changed since the form was loaded' },\n ],\n },\n },\n}\n"],
|
|
5
|
-
"mappings": "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",
|
|
4
|
+
"sourcesContent": ["import { NextResponse } from 'next/server'\nimport { z } from 'zod'\nimport type { AwilixContainer } from 'awilix'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport type { CommandBus } from '@open-mercato/shared/lib/commands'\nimport { isCrudHttpError } from '@open-mercato/shared/lib/crud/errors'\nimport { getCommandInterceptorHttpRejection } from '@open-mercato/shared/lib/commands/errors'\nimport { validateRouteMutationGuard } from '@open-mercato/core/modules/communication_channels/lib/route-mutation-guard'\nimport { CHANNEL_DISCORD_AUTO_REPLY_AGENT_ID } from '../../../../ai-agents'\nimport {\n CHANNEL_DISCORD_CHANNEL_RESOURCE_KIND,\n CHANNEL_DISCORD_UPDATE_AI_AUTO_REPLY_COMMAND_ID,\n discordAiAutoReplySettingsSchema,\n type UpdateAiAutoReplyInput,\n type UpdateAiAutoReplyResult,\n} from '../../../../commands/update-ai-auto-reply'\nimport {\n findDiscordEligibleAgent,\n listDiscordEligibleAgents,\n missingAgentFeatures,\n type AgentInvokingPrincipal,\n} from '../../../../lib/ai-agent-directory'\nimport { CHANNEL_DISCORD_CONFIGURE_FEATURE, CHANNEL_DISCORD_VIEW_FEATURE } from '../../../../lib/ai-features'\nimport { resolveDiscordAiPrincipal } from '../../../../lib/ai-service-principal'\nimport { loadDiscordChannelForRequest } from '../../../../lib/channel-access'\nimport { discordChannelStateSchema } from '../../../../lib/credentials'\n\nexport const metadata = {\n GET: {\n requireAuth: true,\n requireFeatures: [CHANNEL_DISCORD_VIEW_FEATURE],\n },\n PUT: {\n requireAuth: true,\n requireFeatures: [CHANNEL_DISCORD_CONFIGURE_FEATURE],\n },\n}\n\ntype RouteContext = {\n params: Promise<{ id: string }> | { id: string }\n}\n\nfunction fieldError(field: string, message: string, extra?: Record<string, unknown>): Response {\n return NextResponse.json(\n { error: message, fieldErrors: { [field]: message }, ...extra },\n { status: 400 },\n )\n}\n\n/**\n * The identity the auto-reply subscriber will run the chosen agent under, so both\n * methods can answer \"would the runtime accept this?\" with the same grants the\n * subscriber will present \u2014 the tenant's channel-bot user when it exists, the\n * provider service principal otherwise.\n */\nasync function loadInvokingPrincipal(\n container: AwilixContainer,\n scope: { tenantId: string; organizationId: string | null },\n): Promise<AgentInvokingPrincipal> {\n const em = (container.resolve('em') as EntityManager).fork()\n return resolveDiscordAiPrincipal({\n em,\n resolver: { resolve: <T,>(name: string): T => container.resolve(name) as T },\n scope,\n })\n}\n\n/**\n * Read a Discord channel's AI auto-reply settings plus everything the settings\n * form needs to render itself: the eligible agents, whether the optional AI peer\n * is installed at all, and the channel's `updatedAt` so the form can attach the\n * optimistic-lock header on save.\n */\nexport async function GET(req: Request, context: RouteContext): Promise<Response> {\n const { id } = await context.params\n if (!z.string().uuid().safeParse(id).success) {\n return NextResponse.json({ error: 'Invalid channel id' }, { status: 400 })\n }\n\n const auth = await getAuthFromRequest(req)\n if (!auth?.sub || !auth?.tenantId) {\n return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })\n }\n\n const container = await createRequestContainer()\n const access = await loadDiscordChannelForRequest({ container, req, auth, channelId: id, mode: 'read' })\n if ('response' in access) return access.response\n const { channel } = access\n\n const state = discordChannelStateSchema.safeParse(channel.channelState ?? {})\n const directory = await listDiscordEligibleAgents()\n\n // Every offered agent carries whether the auto-reply principal could actually\n // invoke it. Without this the picker offers agents from other modules that the\n // runtime will refuse, and the refusal only ever happens later in a background\n // subscriber \u2014 so the operator sees \"Auto-reply on\" and silence.\n const principal = directory.available\n ? await loadInvokingPrincipal(container, {\n tenantId: auth.tenantId as string,\n organizationId: access.rbacOrganizationId,\n })\n : null\n const agents = directory.available && principal\n ? directory.agents.map((agent) => {\n const missingFeatures = missingAgentFeatures(agent.requiredFeatures, principal)\n return { ...agent, invocable: missingFeatures.length === 0, missingFeatures }\n })\n : []\n\n return NextResponse.json({\n id: channel.id,\n channelId: channel.id,\n displayName: channel.displayName,\n updatedAt: channel.updatedAt ? channel.updatedAt.toISOString() : null,\n aiAutoReplyEnabled: state.success ? Boolean(state.data.aiAutoReplyEnabled) : false,\n aiAgentId: (state.success ? state.data.aiAgentId : undefined) ?? null,\n // Why the last attempt produced nothing, so an armed channel that answers\n // nothing says so instead of looking healthy.\n aiAutoReplyLastError: (state.success ? state.data.aiAutoReplyLastError : undefined) ?? null,\n aiAutoReplyLastErrorAt: (state.success ? state.data.aiAutoReplyLastErrorAt : undefined) ?? null,\n defaultAgentId: CHANNEL_DISCORD_AUTO_REPLY_AGENT_ID,\n aiAvailable: directory.available,\n agents,\n })\n}\n\n/**\n * Write a Discord channel's AI auto-reply settings \u2014 the configuration path the\n * subscriber was missing (issue #4778).\n *\n * The route is deliberately strict about *enabling*: it refuses to arm a channel\n * whose AI peer is absent, one pointed at an agent the runtime would reject on\n * shape, and one pointed at an agent the auto-reply principal is not authorized\n * to invoke. All three would store a setting that can only fail later, inside a\n * background subscriber where nobody sees the error \u2014 which is precisely how the\n * feature ended up dormant in the first place. The three checks mirror, in order,\n * what `runAiAgentObject` \u2192 `checkAgentPolicy` will ask at run time.\n */\nexport async function PUT(req: Request, context: RouteContext): Promise<Response> {\n const { id } = await context.params\n if (!z.string().uuid().safeParse(id).success) {\n return NextResponse.json({ error: 'Invalid channel id' }, { status: 400 })\n }\n\n const auth = await getAuthFromRequest(req)\n if (!auth?.sub || !auth?.tenantId) {\n return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })\n }\n const organizationId = (auth as { orgId?: string | null }).orgId ?? null\n\n const rawBody = await req.json().catch(() => null)\n if (rawBody == null || typeof rawBody !== 'object') {\n return NextResponse.json({ error: 'Request body must be a JSON object' }, { status: 400 })\n }\n\n const parsed = discordAiAutoReplySettingsSchema.safeParse(rawBody)\n if (!parsed.success) {\n const issue = parsed.error.issues[0]\n const field = issue?.path.join('.') || 'aiAutoReplyEnabled'\n return fieldError(field, issue?.message ?? 'Invalid AI auto-reply settings')\n }\n\n const container = await createRequestContainer()\n // Authorize before the guard so a caller who may not touch this channel gets a\n // masked 404 rather than a guard verdict about a record they cannot see. It\n // also comes before the arming checks below, which need the channel's resolved\n // organization to build the principal they judge the agent against.\n const access = await loadDiscordChannelForRequest({ container, req, auth, channelId: id, mode: 'manage' })\n if ('response' in access) return access.response\n\n if (parsed.data.aiAutoReplyEnabled) {\n const agentId = parsed.data.aiAgentId as string\n const agent = await findDiscordEligibleAgent(agentId)\n if (!agent) {\n // No eligible agent under this id: either the optional AI peer is absent, or\n // the agent is not object-mode and `runAiAgentObject` would reject it.\n const directory = await listDiscordEligibleAgents()\n return directory.available\n ? fieldError('aiAgentId', 'channel_discord.aiAutoReply.errors.agentNotEligible')\n : fieldError('aiAutoReplyEnabled', 'channel_discord.aiAutoReply.errors.aiUnavailable')\n }\n\n // Shape is not authorization. `checkAgentPolicy` enforces the agent's\n // `requiredFeatures` against the auto-reply principal at run time, so arming a\n // channel against an agent that principal cannot invoke stores a setting that\n // can only fail later \u2014 inside a background subscriber, where the operator\n // never sees it. Refuse here, naming the grants the tenant's channel-bot user\n // is missing, rather than accepting a channel that would answer nothing.\n const principal = await loadInvokingPrincipal(container, {\n tenantId: auth.tenantId as string,\n organizationId: access.rbacOrganizationId,\n })\n const missingFeatures = missingAgentFeatures(agent.requiredFeatures, principal)\n if (missingFeatures.length > 0) {\n return fieldError(\n 'aiAgentId',\n 'channel_discord.aiAutoReply.errors.agentFeaturesMissing',\n { missingFeatures },\n )\n }\n }\n\n const guard = await validateRouteMutationGuard({\n container,\n req,\n auth,\n input: {\n resourceKind: CHANNEL_DISCORD_CHANNEL_RESOURCE_KIND,\n resourceId: id,\n operation: 'update',\n mutationPayload: { ...parsed.data },\n },\n })\n if ('response' in guard) return guard.response\n\n const commandBus = container.resolve('commandBus') as CommandBus\n const input: UpdateAiAutoReplyInput = {\n channelId: id,\n settings: parsed.data,\n scope: {\n tenantId: auth.tenantId as string,\n organizationId: access.rbacOrganizationId,\n organizationIds: access.organizationIds,\n },\n }\n\n let result: UpdateAiAutoReplyResult\n try {\n const executed = await commandBus.execute<UpdateAiAutoReplyInput, UpdateAiAutoReplyResult>(\n CHANNEL_DISCORD_UPDATE_AI_AUTO_REPLY_COMMAND_ID,\n {\n input,\n ctx: {\n container,\n auth: auth as never,\n organizationScope: null,\n selectedOrganizationId: organizationId,\n organizationIds: organizationId ? [organizationId] : null,\n request: req,\n },\n },\n )\n result = executed.result\n } catch (err) {\n // The optimistic-lock guard throws the shared structured 409 so the form's\n // conflict bar renders the same way it does anywhere else in the product.\n if (isCrudHttpError(err)) {\n return NextResponse.json(err.body, { status: err.status })\n }\n const interceptorRejection = getCommandInterceptorHttpRejection(err)\n if (interceptorRejection) {\n return NextResponse.json(interceptorRejection.body, { status: interceptorRejection.status })\n }\n throw err\n }\n\n if (result.status === 'not_found') {\n return NextResponse.json({ error: 'Channel not found' }, { status: 404 })\n }\n\n await guard.afterSuccess()\n return NextResponse.json(\n {\n channelId: result.channelId,\n aiAutoReplyEnabled: result.aiAutoReplyEnabled,\n aiAgentId: result.aiAgentId,\n updatedAt: result.updatedAt,\n },\n { status: 200 },\n )\n}\n\nexport const openApi = {\n tags: ['CommunicationChannels'],\n methods: {\n GET: {\n summary: \"Read a Discord channel's AI auto-reply configuration\",\n tags: ['CommunicationChannels'],\n responses: [\n { status: 200, description: 'Current settings plus the agents this channel may be pointed at' },\n { status: 400, description: 'Invalid channel id' },\n { status: 401, description: 'Unauthorized' },\n { status: 404, description: 'Channel not found, not a Discord channel in scope, or not the caller\u2019s to see' },\n ],\n },\n PUT: {\n summary: \"Update a Discord channel's AI auto-reply configuration\",\n tags: ['CommunicationChannels'],\n responses: [\n { status: 200, description: 'Settings stored' },\n {\n status: 400,\n description:\n 'Invalid payload, AI module absent, agent not eligible, or the auto-reply principal lacks the agent\u2019s required features',\n },\n { status: 401, description: 'Unauthorized' },\n { status: 404, description: 'Channel not found, or not a Discord channel in this scope' },\n { status: 409, description: 'The channel changed since the form was loaded' },\n ],\n },\n },\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
CHANNEL_DISCORD_AI_PROPOSAL_APPROVE_COMMAND_ID,
|
|
8
8
|
CHANNEL_DISCORD_AI_PROPOSAL_DISMISS_COMMAND_ID
|
|
9
9
|
} from "../lib/ai-proposal-contract.js";
|
|
10
|
+
import { DISCORD_CHANNEL_TYPE } from "../lib/channel-identity.js";
|
|
10
11
|
import { CHANNEL_DISCORD_AI_PROPOSAL_MESSAGE_TYPE } from "../message-types.js";
|
|
11
12
|
const logger = createLogger("channel_discord").child({ component: "ai-reply-proposal" });
|
|
12
13
|
const proposalActionInputSchema = z.object({
|
|
@@ -68,6 +69,12 @@ const approveProposalCommand = {
|
|
|
68
69
|
input: {
|
|
69
70
|
type: inbound.type,
|
|
70
71
|
visibility: "public",
|
|
72
|
+
// Same waiver the automatic tier needs (#5601). The approve path composes
|
|
73
|
+
// the identical public reply through the identical hub command, so an
|
|
74
|
+
// absent channel type breaks it identically: the validator fails closed
|
|
75
|
+
// and demands an `externalEmail` from a snowflake. The human-approved
|
|
76
|
+
// send was as broken as the automatic one.
|
|
77
|
+
sourceChannelType: DISCORD_CHANNEL_TYPE,
|
|
71
78
|
subject: (inbound.subject ?? "Discord reply").toString().slice(0, 200) || "Discord reply",
|
|
72
79
|
body,
|
|
73
80
|
bodyFormat: "markdown",
|
|
@@ -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 { CHANNEL_DISCORD_AI_PROPOSAL_MESSAGE_TYPE } from '../message-types'\n\nconst logger = createLogger('channel_discord').child({ component: 'ai-reply-proposal' })\n\n/**\n * Commands behind the `complex`-tier approval card (issue #4778).\n *\n * `messages.actions.execute` dispatches these after it has atomically claimed\n * the terminal action, so both handlers can assume they run at most once per\n * proposal. It passes `messageId` / `actionId` and the acting operator in\n * `ctx.auth` \u2014 everything else these commands need is read from the stored\n * proposal, never from the request body, so a caller cannot redirect the send.\n */\nconst proposalActionInputSchema = z\n .object({\n messageId: z.string().uuid(),\n actionId: z.string().min(1),\n })\n .passthrough()\n\ntype ProposalActionInput = z.infer<typeof proposalActionInputSchema>\n\nexport type ApproveProposalResult = { ok: true; sentMessageId: string; threadId: string | null }\nexport type DismissProposalResult = { ok: true; dismissed: true }\n\ntype ResolvedScope = { tenantId: string; organizationId: string | null; userId: string }\n\nfunction requireScope(auth: { sub?: string; tenantId?: string | null; orgId?: string | null } | null): ResolvedScope {\n const userId = auth?.sub\n const tenantId = auth?.tenantId\n if (!userId || !tenantId) {\n throw new Error('[internal] channel_discord AI proposal action requires an authenticated actor')\n }\n return { tenantId, organizationId: auth?.orgId ?? null, userId }\n}\n\nasync function loadProposal(\n em: EntityManager,\n messageId: string,\n scope: ResolvedScope,\n): Promise<Message> {\n const proposal = await findOneWithDecryption(\n em,\n Message,\n { id: messageId, tenantId: scope.tenantId, deletedAt: null },\n undefined,\n { tenantId: scope.tenantId, organizationId: scope.organizationId },\n )\n if (!proposal) throw new Error('[internal] AI reply proposal not found')\n if (proposal.type !== CHANNEL_DISCORD_AI_PROPOSAL_MESSAGE_TYPE) {\n // The action is only ever rendered on our own message type; a mismatch means\n // the id was swapped, so refuse rather than sending an unrelated message body\n // into a Discord server.\n throw new Error('[internal] Message is not a Discord AI reply proposal')\n }\n return proposal\n}\n\nconst approveProposalCommand: CommandHandler<ProposalActionInput, ApproveProposalResult> = {\n id: CHANNEL_DISCORD_AI_PROPOSAL_APPROVE_COMMAND_ID,\n // A message posted into a Discord server cannot be recalled by undoing a row\n // here, so the command declines to pretend otherwise.\n isUndoable: false,\n async execute(rawInput, ctx) {\n const input = proposalActionInputSchema.parse(rawInput)\n const scope = requireScope(ctx.auth)\n const em = (ctx.container.resolve('em') as EntityManager).fork()\n\n const proposal = await loadProposal(em, input.messageId, scope)\n const inboundMessageId = proposal.sourceEntityId\n if (!inboundMessageId) {\n throw new Error('[internal] AI reply proposal has no source message to reply to')\n }\n\n const inbound = await findOneWithDecryption(\n em,\n Message,\n { id: inboundMessageId, tenantId: scope.tenantId, deletedAt: null },\n undefined,\n { tenantId: scope.tenantId, organizationId: scope.organizationId },\n )\n if (!inbound) {\n throw new Error('[internal] The message this proposal answers no longer exists')\n }\n\n const body = (proposal.body ?? '').trim()\n if (!body) {\n throw new Error('[internal] AI reply proposal has an empty body')\n }\n\n const commandBus = ctx.container.resolve('commandBus') as CommandBus\n // Same generic outbound path the easy tier uses: compose a public reply in\n // the thread and let the hub's outbound bridge deliver it. Attribution is the\n // approving operator, not the bot \u2014 a human decided to send this.\n const composeResult = await commandBus.execute<\n Record<string, unknown>,\n { id: string; threadId: string | null }\n >('messages.messages.compose', {\n input: {\n type: inbound.type,\n visibility: 'public' as const,\n subject: (inbound.subject ?? 'Discord reply').toString().slice(0, 200) || 'Discord reply',\n body,\n bodyFormat: 'markdown' as const,\n priority: 'normal' as const,\n sendViaEmail: false,\n parentMessageId: inbound.threadId ?? inbound.id,\n isDraft: false,\n tenantId: scope.tenantId,\n organizationId: scope.organizationId,\n userId: scope.userId,\n },\n ctx: {\n container: ctx.container,\n auth: ctx.auth ?? null,\n organizationScope: null,\n selectedOrganizationId: scope.organizationId,\n organizationIds: scope.organizationId ? [scope.organizationId] : null,\n },\n })\n\n const sent = composeResult.result\n logger.info('AI reply proposal approved and sent', {\n proposalId: proposal.id,\n sentMessageId: sent.id,\n })\n return { ok: true, sentMessageId: sent.id, threadId: sent.threadId ?? null }\n },\n}\n\nconst dismissProposalCommand: CommandHandler<ProposalActionInput, DismissProposalResult> = {\n id: CHANNEL_DISCORD_AI_PROPOSAL_DISMISS_COMMAND_ID,\n isUndoable: false,\n async execute(rawInput, ctx) {\n const input = proposalActionInputSchema.parse(rawInput)\n const scope = requireScope(ctx.auth)\n const em = (ctx.container.resolve('em') as EntityManager).fork()\n\n // Nothing to write: `messages.actions.execute` has already recorded the\n // terminal action, which is what \"dismissed\" means. The load is the guard \u2014\n // it keeps the dismiss action from acknowledging a message that is not ours.\n const proposal = await loadProposal(em, input.messageId, scope)\n logger.info('AI reply proposal dismissed', { proposalId: proposal.id })\n return { ok: true, dismissed: true }\n },\n}\n\nregisterCommand(approveProposalCommand as CommandHandler<unknown, unknown>)\nregisterCommand(dismissProposalCommand as CommandHandler<unknown, unknown>)\n\nexport { approveProposalCommand, dismissProposalCommand }\n"],
|
|
5
|
-
"mappings": "AACA,SAAS,SAAS;AAElB,SAAS,uBAAuB;AAChC,SAAS,oBAAoB;AAC7B,SAAS,6BAA6B;AACtC,SAAS,eAAe;AACxB;AAAA,EACE;AAAA,EACA;AAAA,OACK;AACP,SAAS,gDAAgD;AAEzD,MAAM,SAAS,aAAa,iBAAiB,EAAE,MAAM,EAAE,WAAW,oBAAoB,CAAC;AAWvF,MAAM,4BAA4B,EAC/B,OAAO;AAAA,EACN,WAAW,EAAE,OAAO,EAAE,KAAK;AAAA,EAC3B,UAAU,EAAE,OAAO,EAAE,IAAI,CAAC;AAC5B,CAAC,EACA,YAAY;AASf,SAAS,aAAa,MAA+F;AACnH,QAAM,SAAS,MAAM;AACrB,QAAM,WAAW,MAAM;AACvB,MAAI,CAAC,UAAU,CAAC,UAAU;AACxB,UAAM,IAAI,MAAM,+EAA+E;AAAA,EACjG;AACA,SAAO,EAAE,UAAU,gBAAgB,MAAM,SAAS,MAAM,OAAO;AACjE;AAEA,eAAe,aACb,IACA,WACA,OACkB;AAClB,QAAM,WAAW,MAAM;AAAA,IACrB;AAAA,IACA;AAAA,IACA,EAAE,IAAI,WAAW,UAAU,MAAM,UAAU,WAAW,KAAK;AAAA,IAC3D;AAAA,IACA,EAAE,UAAU,MAAM,UAAU,gBAAgB,MAAM,eAAe;AAAA,EACnE;AACA,MAAI,CAAC,SAAU,OAAM,IAAI,MAAM,wCAAwC;AACvE,MAAI,SAAS,SAAS,0CAA0C;AAI9D,UAAM,IAAI,MAAM,uDAAuD;AAAA,EACzE;AACA,SAAO;AACT;AAEA,MAAM,yBAAqF;AAAA,EACzF,IAAI;AAAA;AAAA;AAAA,EAGJ,YAAY;AAAA,EACZ,MAAM,QAAQ,UAAU,KAAK;AAC3B,UAAM,QAAQ,0BAA0B,MAAM,QAAQ;AACtD,UAAM,QAAQ,aAAa,IAAI,IAAI;AACnC,UAAM,KAAM,IAAI,UAAU,QAAQ,IAAI,EAAoB,KAAK;AAE/D,UAAM,WAAW,MAAM,aAAa,IAAI,MAAM,WAAW,KAAK;AAC9D,UAAM,mBAAmB,SAAS;AAClC,QAAI,CAAC,kBAAkB;AACrB,YAAM,IAAI,MAAM,gEAAgE;AAAA,IAClF;AAEA,UAAM,UAAU,MAAM;AAAA,MACpB;AAAA,MACA;AAAA,MACA,EAAE,IAAI,kBAAkB,UAAU,MAAM,UAAU,WAAW,KAAK;AAAA,MAClE;AAAA,MACA,EAAE,UAAU,MAAM,UAAU,gBAAgB,MAAM,eAAe;AAAA,IACnE;AACA,QAAI,CAAC,SAAS;AACZ,YAAM,IAAI,MAAM,+DAA+D;AAAA,IACjF;AAEA,UAAM,QAAQ,SAAS,QAAQ,IAAI,KAAK;AACxC,QAAI,CAAC,MAAM;AACT,YAAM,IAAI,MAAM,gDAAgD;AAAA,IAClE;AAEA,UAAM,aAAa,IAAI,UAAU,QAAQ,YAAY;AAIrD,UAAM,gBAAgB,MAAM,WAAW,QAGrC,6BAA6B;AAAA,MAC7B,OAAO;AAAA,QACL,MAAM,QAAQ;AAAA,QACd,YAAY;AAAA,
|
|
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 { CHANNEL_DISCORD_AI_PROPOSAL_MESSAGE_TYPE } from '../message-types'\n\nconst logger = createLogger('channel_discord').child({ component: 'ai-reply-proposal' })\n\n/**\n * Commands behind the `complex`-tier approval card (issue #4778).\n *\n * `messages.actions.execute` dispatches these after it has atomically claimed\n * the terminal action, so both handlers can assume they run at most once per\n * proposal. It passes `messageId` / `actionId` and the acting operator in\n * `ctx.auth` \u2014 everything else these commands need is read from the stored\n * proposal, never from the request body, so a caller cannot redirect the send.\n */\nconst proposalActionInputSchema = z\n .object({\n messageId: z.string().uuid(),\n actionId: z.string().min(1),\n })\n .passthrough()\n\ntype ProposalActionInput = z.infer<typeof proposalActionInputSchema>\n\nexport type ApproveProposalResult = { ok: true; sentMessageId: string; threadId: string | null }\nexport type DismissProposalResult = { ok: true; dismissed: true }\n\ntype ResolvedScope = { tenantId: string; organizationId: string | null; userId: string }\n\nfunction requireScope(auth: { sub?: string; tenantId?: string | null; orgId?: string | null } | null): ResolvedScope {\n const userId = auth?.sub\n const tenantId = auth?.tenantId\n if (!userId || !tenantId) {\n throw new Error('[internal] channel_discord AI proposal action requires an authenticated actor')\n }\n return { tenantId, organizationId: auth?.orgId ?? null, userId }\n}\n\nasync function loadProposal(\n em: EntityManager,\n messageId: string,\n scope: ResolvedScope,\n): Promise<Message> {\n const proposal = await findOneWithDecryption(\n em,\n Message,\n { id: messageId, tenantId: scope.tenantId, deletedAt: null },\n undefined,\n { tenantId: scope.tenantId, organizationId: scope.organizationId },\n )\n if (!proposal) throw new Error('[internal] AI reply proposal not found')\n if (proposal.type !== CHANNEL_DISCORD_AI_PROPOSAL_MESSAGE_TYPE) {\n // The action is only ever rendered on our own message type; a mismatch means\n // the id was swapped, so refuse rather than sending an unrelated message body\n // into a Discord server.\n throw new Error('[internal] Message is not a Discord AI reply proposal')\n }\n return proposal\n}\n\nconst approveProposalCommand: CommandHandler<ProposalActionInput, ApproveProposalResult> = {\n id: CHANNEL_DISCORD_AI_PROPOSAL_APPROVE_COMMAND_ID,\n // A message posted into a Discord server cannot be recalled by undoing a row\n // here, so the command declines to pretend otherwise.\n isUndoable: false,\n async execute(rawInput, ctx) {\n const input = proposalActionInputSchema.parse(rawInput)\n const scope = requireScope(ctx.auth)\n const em = (ctx.container.resolve('em') as EntityManager).fork()\n\n const proposal = await loadProposal(em, input.messageId, scope)\n const inboundMessageId = proposal.sourceEntityId\n if (!inboundMessageId) {\n throw new Error('[internal] AI reply proposal has no source message to reply to')\n }\n\n const inbound = await findOneWithDecryption(\n em,\n Message,\n { id: inboundMessageId, tenantId: scope.tenantId, deletedAt: null },\n undefined,\n { tenantId: scope.tenantId, organizationId: scope.organizationId },\n )\n if (!inbound) {\n throw new Error('[internal] The message this proposal answers no longer exists')\n }\n\n const body = (proposal.body ?? '').trim()\n if (!body) {\n throw new Error('[internal] AI reply proposal has an empty body')\n }\n\n const commandBus = ctx.container.resolve('commandBus') as CommandBus\n // Same generic outbound path the easy tier uses: compose a public reply in\n // the thread and let the hub's outbound bridge deliver it. Attribution is the\n // approving operator, not the bot \u2014 a human decided to send this.\n const composeResult = await commandBus.execute<\n Record<string, unknown>,\n { id: string; threadId: string | null }\n >('messages.messages.compose', {\n input: {\n type: inbound.type,\n visibility: 'public' as const,\n // 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
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { discordCapabilities } from "./capabilities.js";
|
|
2
|
+
import { DISCORD_CHANNEL_TYPE, DISCORD_PROVIDER_KEY } from "./channel-identity.js";
|
|
2
3
|
import { parseDiscordCredentialsOrThrow, discordCredentialsSchema } from "./credentials.js";
|
|
3
4
|
import {
|
|
4
5
|
DiscordApiError,
|
|
@@ -15,8 +16,8 @@ import {
|
|
|
15
16
|
} from "./interactions-verify.js";
|
|
16
17
|
class DiscordChannelAdapter {
|
|
17
18
|
constructor() {
|
|
18
|
-
this.providerKey =
|
|
19
|
-
this.channelType =
|
|
19
|
+
this.providerKey = DISCORD_PROVIDER_KEY;
|
|
20
|
+
this.channelType = DISCORD_CHANNEL_TYPE;
|
|
20
21
|
this.capabilities = discordCapabilities;
|
|
21
22
|
}
|
|
22
23
|
async sendMessage(input) {
|