@open-mercato/channel-discord 0.7.1-develop.7181.1.702cedc42c → 0.7.1-develop.7183.1.db9678eeb8
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/lib/convert-outbound.js +4 -1
- package/dist/modules/channel_discord/lib/convert-outbound.js.map +2 -2
- package/package.json +8 -8
- package/src/modules/channel_discord/lib/__tests__/convert-outbound.test.ts +18 -0
- package/src/modules/channel_discord/lib/convert-outbound.ts +12 -18
|
@@ -1,8 +1,11 @@
|
|
|
1
|
+
import { htmlToPlainText } from "@open-mercato/shared/lib/html/htmlToPlainText";
|
|
2
|
+
import { sanitizeRichTextHtml } from "@open-mercato/shared/lib/html/sanitizeRichText";
|
|
1
3
|
import { createLogger } from "@open-mercato/shared/lib/logger";
|
|
2
4
|
import { DISCORD_MAX_BODY_LENGTH } from "./capabilities.js";
|
|
3
5
|
const logger = createLogger("channel_discord").child({ component: "convert-outbound" });
|
|
4
6
|
function htmlToMarkdown(html) {
|
|
5
|
-
|
|
7
|
+
const markdownHtml = sanitizeRichTextHtml(html).replace(/<(strong|b)>(.*?)<\/\1>/gis, "**$2**").replace(/<(em|i)>(.*?)<\/\1>/gis, "*$2*").replace(/<code>(.*?)<\/code>/gis, "`$1`").replace(/<a href="([^"]+)"[^>]*>(.*?)<\/a>/gis, "[$2]($1)").replace(/<img\b[^>]*>/gi, "");
|
|
8
|
+
return htmlToPlainText(markdownHtml);
|
|
6
9
|
}
|
|
7
10
|
async function convertOutboundForDiscord(input) {
|
|
8
11
|
const droppedAttachmentCount = input.attachments?.length ?? 0;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../src/modules/channel_discord/lib/convert-outbound.ts"],
|
|
4
|
-
"sourcesContent": ["import type {\n ChannelNativeContent,\n ConvertOutboundInput,\n} from '@open-mercato/core/modules/communication_channels/lib/adapter'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\nimport { DISCORD_MAX_BODY_LENGTH } from './capabilities'\n\nconst logger = createLogger('channel_discord').child({ component: 'convert-outbound' })\n\n/**\n * Very small HTML \u2192 markdown down-converter for the common inline tags the hub's\n * `html` body format produces. Discord content is markdown-native, so we map the\n * handful of tags that have a markdown equivalent
|
|
5
|
-
"mappings": "AAIA,SAAS,oBAAoB;AAC7B,SAAS,+BAA+B;AAExC,MAAM,SAAS,aAAa,iBAAiB,EAAE,MAAM,EAAE,WAAW,mBAAmB,CAAC;AAQtF,SAAS,eAAe,MAAsB;AAC5C,
|
|
4
|
+
"sourcesContent": ["import type {\n ChannelNativeContent,\n ConvertOutboundInput,\n} from '@open-mercato/core/modules/communication_channels/lib/adapter'\nimport { htmlToPlainText } from '@open-mercato/shared/lib/html/htmlToPlainText'\nimport { sanitizeRichTextHtml } from '@open-mercato/shared/lib/html/sanitizeRichText'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\nimport { DISCORD_MAX_BODY_LENGTH } from './capabilities'\n\nconst logger = createLogger('channel_discord').child({ component: 'convert-outbound' })\n\n/**\n * Very small HTML \u2192 markdown down-converter for the common inline tags the hub's\n * `html` body format produces. Discord content is markdown-native, so we map the\n * handful of tags that have a markdown equivalent, then reuse the shared HTML\n * sanitizer and parser for the remainder.\n */\nfunction htmlToMarkdown(html: string): string {\n const markdownHtml = sanitizeRichTextHtml(html)\n .replace(/<(strong|b)>(.*?)<\\/\\1>/gis, '**$2**')\n .replace(/<(em|i)>(.*?)<\\/\\1>/gis, '*$2*')\n .replace(/<code>(.*?)<\\/code>/gis, '`$1`')\n .replace(/<a href=\"([^\"]+)\"[^>]*>(.*?)<\\/a>/gis, '[$2]($1)')\n .replace(/<img\\b[^>]*>/gi, '')\n\n return htmlToPlainText(markdownHtml)\n}\n\n/**\n * Convert the hub's normalized outbound body into Discord native content.\n *\n * - `markdown` / `text` pass through unchanged (Discord is markdown-native).\n * - `html` is down-converted to markdown.\n * - Content is clamped to Discord's 2000-char hard limit (a longer body is\n * truncated with an ellipsis marker rather than rejected by the API).\n * - `allowed_mentions` defaults to `{ parse: [] }` so an AI/automated reply can\n * never accidentally @-ping everyone; callers can widen it via `channelMetadata`.\n * - Attachments are NOT uploaded: `discord-rest` has no multipart upload, so the\n * capability profile declares `fileSharing: false` and the hub should never\n * route an attachment here. If one arrives anyway we log it rather than drop\n * it silently, and report the count in `metadata.droppedAttachmentCount` so the\n * caller can surface the gap.\n */\nexport async function convertOutboundForDiscord(input: ConvertOutboundInput): Promise<ChannelNativeContent> {\n const droppedAttachmentCount = input.attachments?.length ?? 0\n if (droppedAttachmentCount > 0) {\n logger.warn('discord outbound attachments are not supported \u2014 sending text only', {\n droppedAttachmentCount,\n })\n }\n\n const raw = input.body ?? ''\n const markdown = input.bodyFormat === 'html' ? htmlToMarkdown(raw) : raw\n\n const TRUNCATE_MARKER = '\u2026'\n const content =\n markdown.length > DISCORD_MAX_BODY_LENGTH\n ? markdown.slice(0, DISCORD_MAX_BODY_LENGTH - TRUNCATE_MARKER.length) + TRUNCATE_MARKER\n : markdown\n\n const allowedMentions =\n (input.channelMetadata?.allowedMentions as Record<string, unknown> | undefined) ?? { parse: [] }\n\n return {\n content: {\n text: content,\n bodyFormat: 'markdown',\n },\n metadata: {\n allowedMentions,\n droppedAttachmentCount,\n messageReferenceId: resolveMessageReferenceId(input.channelMetadata),\n },\n }\n}\n\n/**\n * Resolve the id of the message an outbound reply attaches to. Order of\n * precedence:\n * 1. `replyToExternalId` \u2014 what the hub writes on the outbound path\n * (`communication_channels/lib/outbound-reply-ref.ts`) when the message\n * being delivered answers one that already reached this channel.\n * 2. `messageReferenceId` \u2014 our own already-converted key, which survives the\n * hub's convert\u2192send double-conversion: `deliver-outbound-message` calls\n * `convertOutbound` and then hands `converted.metadata` to `sendMessage`,\n * which re-converts it. Without accepting the converted key on that second\n * pass the rename `replyToExternalId` \u2192 `messageReferenceId` is one-way and\n * the reference is silently dropped before it reaches Discord (#5541).\n * `channel-gmail/lib/convert-outbound.ts` keeps `threadId` stable across\n * the same seam for the same reason.\n *\n * Both are absent on a non-reply, which sends a plain channel message.\n */\nfunction resolveMessageReferenceId(\n channelMetadata: ConvertOutboundInput['channelMetadata'],\n): string | undefined {\n const fromHub = channelMetadata?.replyToExternalId\n if (typeof fromHub === 'string' && fromHub.length > 0) return fromHub\n const alreadyConverted = channelMetadata?.messageReferenceId\n if (typeof alreadyConverted === 'string' && alreadyConverted.length > 0) return alreadyConverted\n return undefined\n}\n"],
|
|
5
|
+
"mappings": "AAIA,SAAS,uBAAuB;AAChC,SAAS,4BAA4B;AACrC,SAAS,oBAAoB;AAC7B,SAAS,+BAA+B;AAExC,MAAM,SAAS,aAAa,iBAAiB,EAAE,MAAM,EAAE,WAAW,mBAAmB,CAAC;AAQtF,SAAS,eAAe,MAAsB;AAC5C,QAAM,eAAe,qBAAqB,IAAI,EAC3C,QAAQ,8BAA8B,QAAQ,EAC9C,QAAQ,0BAA0B,MAAM,EACxC,QAAQ,0BAA0B,MAAM,EACxC,QAAQ,wCAAwC,UAAU,EAC1D,QAAQ,kBAAkB,EAAE;AAE/B,SAAO,gBAAgB,YAAY;AACrC;AAiBA,eAAsB,0BAA0B,OAA4D;AAC1G,QAAM,yBAAyB,MAAM,aAAa,UAAU;AAC5D,MAAI,yBAAyB,GAAG;AAC9B,WAAO,KAAK,2EAAsE;AAAA,MAChF;AAAA,IACF,CAAC;AAAA,EACH;AAEA,QAAM,MAAM,MAAM,QAAQ;AAC1B,QAAM,WAAW,MAAM,eAAe,SAAS,eAAe,GAAG,IAAI;AAErE,QAAM,kBAAkB;AACxB,QAAM,UACJ,SAAS,SAAS,0BACd,SAAS,MAAM,GAAG,0BAA0B,gBAAgB,MAAM,IAAI,kBACtE;AAEN,QAAM,kBACH,MAAM,iBAAiB,mBAA2D,EAAE,OAAO,CAAC,EAAE;AAEjG,SAAO;AAAA,IACL,SAAS;AAAA,MACP,MAAM;AAAA,MACN,YAAY;AAAA,IACd;AAAA,IACA,UAAU;AAAA,MACR;AAAA,MACA;AAAA,MACA,oBAAoB,0BAA0B,MAAM,eAAe;AAAA,IACrE;AAAA,EACF;AACF;AAmBA,SAAS,0BACP,iBACoB;AACpB,QAAM,UAAU,iBAAiB;AACjC,MAAI,OAAO,YAAY,YAAY,QAAQ,SAAS,EAAG,QAAO;AAC9D,QAAM,mBAAmB,iBAAiB;AAC1C,MAAI,OAAO,qBAAqB,YAAY,iBAAiB,SAAS,EAAG,QAAO;AAChF,SAAO;AACT;",
|
|
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.7.1-develop.
|
|
3
|
+
"version": "0.7.1-develop.7183.1.db9678eeb8",
|
|
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.7.1-develop.
|
|
66
|
-
"@open-mercato/queue": "0.7.1-develop.
|
|
67
|
-
"@open-mercato/ui": "0.7.1-develop.
|
|
65
|
+
"@open-mercato/core": "0.7.1-develop.7183.1.db9678eeb8",
|
|
66
|
+
"@open-mercato/queue": "0.7.1-develop.7183.1.db9678eeb8",
|
|
67
|
+
"@open-mercato/ui": "0.7.1-develop.7183.1.db9678eeb8"
|
|
68
68
|
},
|
|
69
69
|
"peerDependencies": {
|
|
70
70
|
"@mikro-orm/postgresql": "^7.0.14",
|
|
71
|
-
"@open-mercato/ai-assistant": "0.7.1-develop.
|
|
72
|
-
"@open-mercato/shared": "0.7.1-develop.
|
|
71
|
+
"@open-mercato/ai-assistant": "0.7.1-develop.7183.1.db9678eeb8",
|
|
72
|
+
"@open-mercato/shared": "0.7.1-develop.7183.1.db9678eeb8",
|
|
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.7.1-develop.
|
|
83
|
-
"@open-mercato/shared": "0.7.1-develop.
|
|
82
|
+
"@open-mercato/ai-assistant": "0.7.1-develop.7183.1.db9678eeb8",
|
|
83
|
+
"@open-mercato/shared": "0.7.1-develop.7183.1.db9678eeb8",
|
|
84
84
|
"@types/jest": "^30.0.0",
|
|
85
85
|
"@types/react": "^19.2.18",
|
|
86
86
|
"@types/react-dom": "^19.2.5",
|
|
@@ -17,6 +17,24 @@ describe('convertOutboundForDiscord', () => {
|
|
|
17
17
|
expect(result.content.text).not.toContain('<')
|
|
18
18
|
})
|
|
19
19
|
|
|
20
|
+
it('sanitizes executable markup before converting HTML', async () => {
|
|
21
|
+
const result = await convertOutboundForDiscord({
|
|
22
|
+
body: '<p>Safe</p><script>alert(1)</script><img src=x onerror=alert(2)>',
|
|
23
|
+
bodyFormat: 'html',
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
expect(result.content.text).toBe('Safe')
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('decodes HTML entities exactly once', async () => {
|
|
30
|
+
const result = await convertOutboundForDiscord({
|
|
31
|
+
body: '<p>&lt;script&gt;</p>',
|
|
32
|
+
bodyFormat: 'html',
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
expect(result.content.text).toBe('<script>')
|
|
36
|
+
})
|
|
37
|
+
|
|
20
38
|
it('clamps content to the 2000-char limit', async () => {
|
|
21
39
|
const result = await convertOutboundForDiscord({ body: 'x'.repeat(5000), bodyFormat: 'text' })
|
|
22
40
|
expect((result.content.text ?? '').length).toBe(2000)
|
|
@@ -2,6 +2,8 @@ import type {
|
|
|
2
2
|
ChannelNativeContent,
|
|
3
3
|
ConvertOutboundInput,
|
|
4
4
|
} from '@open-mercato/core/modules/communication_channels/lib/adapter'
|
|
5
|
+
import { htmlToPlainText } from '@open-mercato/shared/lib/html/htmlToPlainText'
|
|
6
|
+
import { sanitizeRichTextHtml } from '@open-mercato/shared/lib/html/sanitizeRichText'
|
|
5
7
|
import { createLogger } from '@open-mercato/shared/lib/logger'
|
|
6
8
|
import { DISCORD_MAX_BODY_LENGTH } from './capabilities'
|
|
7
9
|
|
|
@@ -10,26 +12,18 @@ const logger = createLogger('channel_discord').child({ component: 'convert-outbo
|
|
|
10
12
|
/**
|
|
11
13
|
* Very small HTML → markdown down-converter for the common inline tags the hub's
|
|
12
14
|
* `html` body format produces. Discord content is markdown-native, so we map the
|
|
13
|
-
* handful of tags that have a markdown equivalent
|
|
14
|
-
*
|
|
15
|
+
* handful of tags that have a markdown equivalent, then reuse the shared HTML
|
|
16
|
+
* sanitizer and parser for the remainder.
|
|
15
17
|
*/
|
|
16
18
|
function htmlToMarkdown(html: string): string {
|
|
17
|
-
|
|
18
|
-
.replace(
|
|
19
|
-
.replace(
|
|
20
|
-
.replace(
|
|
21
|
-
.replace(
|
|
22
|
-
.replace(
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
.replace(/<[^>]+>/g, '')
|
|
26
|
-
.replace(/ /gi, ' ')
|
|
27
|
-
.replace(/&/gi, '&')
|
|
28
|
-
.replace(/</gi, '<')
|
|
29
|
-
.replace(/>/gi, '>')
|
|
30
|
-
.replace(/"/gi, '"')
|
|
31
|
-
.replace(/\n{3,}/g, '\n\n')
|
|
32
|
-
.trim()
|
|
19
|
+
const markdownHtml = sanitizeRichTextHtml(html)
|
|
20
|
+
.replace(/<(strong|b)>(.*?)<\/\1>/gis, '**$2**')
|
|
21
|
+
.replace(/<(em|i)>(.*?)<\/\1>/gis, '*$2*')
|
|
22
|
+
.replace(/<code>(.*?)<\/code>/gis, '`$1`')
|
|
23
|
+
.replace(/<a href="([^"]+)"[^>]*>(.*?)<\/a>/gis, '[$2]($1)')
|
|
24
|
+
.replace(/<img\b[^>]*>/gi, '')
|
|
25
|
+
|
|
26
|
+
return htmlToPlainText(markdownHtml)
|
|
33
27
|
}
|
|
34
28
|
|
|
35
29
|
/**
|