@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.
@@ -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
- return html.replace(/<\s*br\s*\/?\s*>/gi, "\n").replace(/<\s*\/\s*p\s*>/gi, "\n\n").replace(/<\s*p[^>]*>/gi, "").replace(/<\s*(strong|b)\s*>(.*?)<\s*\/\s*\1\s*>/gis, "**$2**").replace(/<\s*(em|i)\s*>(.*?)<\s*\/\s*\1\s*>/gis, "*$2*").replace(/<\s*code\s*>(.*?)<\s*\/\s*code\s*>/gis, "`$1`").replace(/<\s*a[^>]*href=["']([^"']+)["'][^>]*>(.*?)<\s*\/\s*a\s*>/gis, "[$2]($1)").replace(/<[^>]+>/g, "").replace(/&nbsp;/gi, " ").replace(/&amp;/gi, "&").replace(/&lt;/gi, "<").replace(/&gt;/gi, ">").replace(/&quot;/gi, '"').replace(/\n{3,}/g, "\n\n").trim();
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 and strip the rest rather than\n * pulling in a full HTML parser dependency.\n */\nfunction htmlToMarkdown(html: string): string {\n return html\n .replace(/<\\s*br\\s*\\/?\\s*>/gi, '\\n')\n .replace(/<\\s*\\/\\s*p\\s*>/gi, '\\n\\n')\n .replace(/<\\s*p[^>]*>/gi, '')\n .replace(/<\\s*(strong|b)\\s*>(.*?)<\\s*\\/\\s*\\1\\s*>/gis, '**$2**')\n .replace(/<\\s*(em|i)\\s*>(.*?)<\\s*\\/\\s*\\1\\s*>/gis, '*$2*')\n .replace(/<\\s*code\\s*>(.*?)<\\s*\\/\\s*code\\s*>/gis, '`$1`')\n .replace(/<\\s*a[^>]*href=[\"']([^\"']+)[\"'][^>]*>(.*?)<\\s*\\/\\s*a\\s*>/gis, '[$2]($1)')\n .replace(/<[^>]+>/g, '')\n .replace(/&nbsp;/gi, ' ')\n .replace(/&amp;/gi, '&')\n .replace(/&lt;/gi, '<')\n .replace(/&gt;/gi, '>')\n .replace(/&quot;/gi, '\"')\n .replace(/\\n{3,}/g, '\\n\\n')\n .trim()\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,oBAAoB;AAC7B,SAAS,+BAA+B;AAExC,MAAM,SAAS,aAAa,iBAAiB,EAAE,MAAM,EAAE,WAAW,mBAAmB,CAAC;AAQtF,SAAS,eAAe,MAAsB;AAC5C,SAAO,KACJ,QAAQ,sBAAsB,IAAI,EAClC,QAAQ,oBAAoB,MAAM,EAClC,QAAQ,iBAAiB,EAAE,EAC3B,QAAQ,6CAA6C,QAAQ,EAC7D,QAAQ,yCAAyC,MAAM,EACvD,QAAQ,yCAAyC,MAAM,EACvD,QAAQ,+DAA+D,UAAU,EACjF,QAAQ,YAAY,EAAE,EACtB,QAAQ,YAAY,GAAG,EACvB,QAAQ,WAAW,GAAG,EACtB,QAAQ,UAAU,GAAG,EACrB,QAAQ,UAAU,GAAG,EACrB,QAAQ,YAAY,GAAG,EACvB,QAAQ,WAAW,MAAM,EACzB,KAAK;AACV;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;",
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.7181.1.702cedc42c",
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.7181.1.702cedc42c",
66
- "@open-mercato/queue": "0.7.1-develop.7181.1.702cedc42c",
67
- "@open-mercato/ui": "0.7.1-develop.7181.1.702cedc42c"
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.7181.1.702cedc42c",
72
- "@open-mercato/shared": "0.7.1-develop.7181.1.702cedc42c",
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.7181.1.702cedc42c",
83
- "@open-mercato/shared": "0.7.1-develop.7181.1.702cedc42c",
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>&amp;lt;script&amp;gt;</p>',
32
+ bodyFormat: 'html',
33
+ })
34
+
35
+ expect(result.content.text).toBe('&lt;script&gt;')
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 and strip the rest rather than
14
- * pulling in a full HTML parser dependency.
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
- return html
18
- .replace(/<\s*br\s*\/?\s*>/gi, '\n')
19
- .replace(/<\s*\/\s*p\s*>/gi, '\n\n')
20
- .replace(/<\s*p[^>]*>/gi, '')
21
- .replace(/<\s*(strong|b)\s*>(.*?)<\s*\/\s*\1\s*>/gis, '**$2**')
22
- .replace(/<\s*(em|i)\s*>(.*?)<\s*\/\s*\1\s*>/gis, '*$2*')
23
- .replace(/<\s*code\s*>(.*?)<\s*\/\s*code\s*>/gis, '`$1`')
24
- .replace(/<\s*a[^>]*href=["']([^"']+)["'][^>]*>(.*?)<\s*\/\s*a\s*>/gis, '[$2]($1)')
25
- .replace(/<[^>]+>/g, '')
26
- .replace(/&nbsp;/gi, ' ')
27
- .replace(/&amp;/gi, '&')
28
- .replace(/&lt;/gi, '<')
29
- .replace(/&gt;/gi, '>')
30
- .replace(/&quot;/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
  /**