Skip to content

feat(twenty-slack): unfurl Twenty record links pasted in Slack - #24108

Open
abdulrahmancodes wants to merge 8 commits into
mainfrom
feat/slack-record-link-unfurls
Open

feat(twenty-slack): unfurl Twenty record links pasted in Slack#24108
abdulrahmancodes wants to merge 8 commits into
mainfrom
feat/slack-record-link-unfurls

Conversation

@abdulrahmancodes

@abdulrahmancodes abdulrahmancodes commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Why

Pasting a Twenty record URL in Slack shows a plain link: the record page is an auth-gated SPA, so Slack's generic unfurl carries nothing useful, and #24054 turned unfurls off in assistant replies for that reason. Tier-1 CRM integrations expand their record links into rich cards; this brings the same to Twenty links people paste.

Closes twentyhq/core-team-issues#2769.

What it does

The manifest subscribes to link_shared (with links:read / links:write and an App Unfurl Domain for the workspace). The events resolver routes the event to a new slack-link-unfurl logic function that runs in the resolved workspace: it parses links matching /object/<objectNameSingular>/<recordId> against the workspace's own base URL, fetches each record, and attaches a compact card via chat.unfurl — the linked record name plus a few key fields per object type:

  • opportunity: stage, amount, close date, company
  • person: company, job title, email
  • company: domain, city, ARR
  • task: status, due date, assignee
  • note: created, created by

Anything that does not resolve — a non-record path, another origin, a non-UUID id, an unsupported object, a deleted or inaccessible record — is skipped silently: no error card, and chat.unfurl is never called when nothing resolves. Lookups are capped at 5 links per message. Composer previews (typed but not yet sent) are skipped; the posted message triggers its own event. Slack HTML-escapes URLs in link_shared payloads, so the parser decodes for matching but keeps the original string as the unfurl key.

Permissions

Logic functions run under the app's default role, which held no CRM access by design, and LogicFunctionManifest has no per-function role field (the constraint written down in #24054). The default role therefore gains read-only access to exactly the objects the Slack Assistant role can read (people, companies, opportunities, notes, tasks, plus workspace members for the assignee name), so a card can never expose more than the assistant itself. Anyone in the channel sees the card, so it reads with that shared role, not the poster's Twenty permissions — same trade-off as the assistant, and SETUP.md says so.

Not in this PR

  • Admin-configurable field selection — fields are hardcoded per object type, as the issue scopes it; configurability is the stated follow-up.
  • Unfurls in assistant replies — the worker still posts with unfurl_links off; this only covers links people paste.

Version

Bumped to 0.5.0 — 0.4.0 is claimed by #23985 and 0.3.1 by #24097; the versions compose in any merge order. Existing installs must add the link_shared event, the unfurl domain and the two links:* scopes to their Slack app, then disconnect and reconnect to re-authorize — the feature stays off (events simply don't arrive) until they do.

Testing

yarn test:unit in packages/twenty-apps/public/slack: 22 suites, 143 tests. yarn typecheck, yarn lint and twenty dev:build clean.

New coverage: parse-slack-link-shared-event.test.ts (routing, dedupe, composer skip), parse-slack-record-link.test.ts (origin/path/UUID matching, escaped URLs kept as unfurl keys), format-slack-unfurl-field-values.test.ts (select, date, currency micros), build-slack-record-unfurl-attachment.test.ts (block layout, mrkdwn escaping), and unfurl-slack-record-links.test.ts (happy path, silent skips, partial resolution, chat.unfurl failure).


Generated by Claude Code

Review in cubic

Subscribe to link_shared events and expand pasted record links
(/object/<objectNameSingular>/<recordId>) into compact cards via
chat.unfurl: the record name plus a few key fields per object type
(people, companies, opportunities, notes, tasks).

- Route link_shared through slack-events-resolver to a new
  slack-link-unfurl logic function running in the resolved workspace
- Only unfurl links on the workspace's own base URL, cap lookups at 5
  per message, and skip unresolvable or inaccessible records silently
- Add links:read / links:write scopes, the link_shared bot event and
  an unfurl domain placeholder to the Slack app manifest
- Grant the app function role read-only access to the CRM objects the
  Slack Assistant role can read, so unfurls never expose more than the
  assistant itself
- Document setup (unfurl domain, re-authorization for existing
  installs) and behaviour in SETUP.md / README.md; bump app to 0.5.0
  (0.4.0 is claimed by #23985 and 0.3.1 by #24097)
@twenty-ci-bot-public

Copy link
Copy Markdown

👋 Thanks for contributing to Twenty!

Your PR has been set to draft while you work on it. Once you're done, mark it as Ready for review and our automated checks will run.

Looking forward to your contribution!

@github-actions

github-actions Bot commented Aug 13, 2026

Copy link
Copy Markdown
Contributor
Warnings
⚠️

Changes were made to package.json, but not to yarn.lock - Perhaps you need to run yarn install?

Generated by 🚫 dangerJS against a06e37c

@twenty-ci-bot-public

twenty-ci-bot-public Bot commented Aug 13, 2026

Copy link
Copy Markdown

✅ Standard review · no findings

Safe to merge — no outstanding findings

High-level — Additive, reconnect-gated Slack unfurl feature; sub-1000 non-test lines, no migration, and the read-only role widening (human-answered) mirrors the assistant role's existing exposure — no high-level findings.
Low-level — Line-by-line is clean — precise typing with Pick<>/per-object shapes and isDefined guards, bounded parallel lookups, correct file/naming conventions, and only allowed external-constraint comments; every prior nit and bot finding is fixed in code or human-answered.


Reviewed against the pr-review standard — high-level then low-level. Advisory; human review still required. Run details.

}

try {
const client = new CoreApiClient() as unknown as CoreRecordQueryClient;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Nit · Low-level · typing — no as casts

new CoreApiClient() as unknown as CoreRecordQueryClient plus Record<string, any>/record: any defeat typing

The standard bans as unknown as and any in favour of a real type, mapper, or Pick<>; the accompanying comment justifies but doesn't exempt it. Consider a thin typed wrapper around the generated client's query surface so the cast and the any records disappear.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The as unknown as and the any-typed records are gone since 74e47e3 — the fetcher now uses per-object typed record shapes (PersonUnfurlRecord etc.) with typed per-object fetchers. One plain as cast remains, in the single coreQuery helper, and it is irreducible today: the generated CoreApiClient ships as an untyped stub at build time (its schema is generated per workspace on install), so any typed wrapper would contain exactly this cast — moving it, not removing it. The cast site carries a comment saying precisely that.


Generated by Claude Code

@greptile-apps

greptile-apps Bot commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

The PR adds Slack unfurls for supported Twenty record links, including event routing, bounded record lookup and card formatting, Slack scopes, read-only function-role permissions, setup documentation, and unit coverage.

  • Routes link_shared events to a workspace-scoped unfurl function.
  • Parses same-origin record links and fetches compact cards for five CRM object types.
  • Canonicalizes embedded links and bounds Block Kit content before calling chat.unfurl.
  • Adds the required Slack manifest scopes, event subscription, unfurl domain, and role permissions.
  • Updates setup documentation and bumps the Slack app package to 0.5.0.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains; both previously reported attachment-construction issues are fixed in the current code.

Important Files Changed

Filename Overview
packages/twenty-apps/public/slack/src/logic-functions/utils/build-slack-record-unfurl-attachment.ts The prior Block Kit length and raw-URL mrkdwn findings are addressed with conservative truncation, canonical URLs, delimiter encoding, and escaping.
packages/twenty-apps/public/slack/src/logic-functions/utils/parse-slack-record-link.ts Restricts unfurls to same-origin record paths with UUID identifiers and emits a bounded, query-free URL for card navigation.
packages/twenty-apps/public/slack/src/logic-functions/utils/unfurl-slack-record-links.ts Coordinates capped parsing, record resolution, attachment construction, and one chat.unfurl request while silently skipping unresolved links.
packages/twenty-apps/public/slack/src/logic-functions/utils/fetch-slack-record-unfurl-card.ts Fetches a fixed selection of display fields for the five supported CRM object types and skips inaccessible or unsupported records.
packages/twenty-apps/public/slack/src/roles/default-function.role.ts Grants the function role read-only access to the objects required for record cards, consistent with the documented shared-role model.
packages/twenty-apps/public/slack/slack-app-manifest.json Adds the unfurl domain, link scopes, and link_shared event subscription required by Slack.

Reviews (3): Last reviewed commit: "style(twenty-slack): narrow fetchSlackRe..." | Re-trigger Greptile

- Decode Slack's HTML-escaped URLs in a single pass so pre-escaped
  sequences like &amp;lt; are not unescaped twice (CodeQL)
- Truncate record titles and field values before escaping so oversized
  CRM values cannot push a block past Slack's section/field limits and
  void the whole chat.unfurl batch
- Percent-encode <, > and | in the embedded link so a query string
  cannot break mrkdwn <url|label> syntax
- Replace the untyped CoreApiClient cast and any-typed records with
  per-object typed fetchers behind one narrow, documented cast
- Split the three unfurl formatters into one util per file

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

5 issues found and verified against the latest diff

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="packages/twenty-apps/public/slack/src/logic-functions/utils/unfurl-slack-record-links.ts">

<violation number="1" location="packages/twenty-apps/public/slack/src/logic-functions/utils/unfurl-slack-record-links.ts:41">
P2: When loading the Slack connection fails transiently, this branch acknowledges the link event as skipped and prevents the logic-function runtime from retrying it. Throw the connection error for infrastructure failures; reserve `ok: true` skips for valid non-record or intentionally disabled inputs.</violation>

<violation number="2" location="packages/twenty-apps/public/slack/src/logic-functions/utils/unfurl-slack-record-links.ts:81">
P2: When `chat.unfurl` fails transiently, this catch returns a successful Promise with an application-level `ok: false`, so the logic-function runtime has no failure to retry and the record links remain unfurled. Throw the error after logging it so the event execution is retried.</violation>
</file>

<file name="packages/twenty-apps/public/slack/SETUP.md">

<violation number="1" location="packages/twenty-apps/public/slack/SETUP.md:80">
P3: The new 'Record link unfurls' step shifted 'Role' from step 4 to step 5, so the Channel welcome behaviour note's reference to 'the shared-role caveat from step 4 above' is now stale — step 4 is Reconnect. Update that reference to 'step 5'.</violation>
</file>

<file name="packages/twenty-apps/public/slack/src/logic-functions/utils/parse-slack-link-shared-event.ts">

<violation number="1" location="packages/twenty-apps/public/slack/src/logic-functions/utils/parse-slack-link-shared-event.ts:41">
P2: Composer previews are detected only by the absence of `message_ts`, while the PR adds a `source` field to the event type (which Slack sets to `"composer"` for composer previews) but never reads it here. Slack identifies link_shared events that have no posted message via `source`/`unfurl_id`; relying on a missing `message_ts` is a proxy that would silently unfurl to a `COMPOSER` channel if Slack ever includes a timestamp on composer events. Check `event.source === 'composer'` (or the presence of `event.unfurl_id`) explicitly.</violation>
</file>

<file name="packages/twenty-apps/public/slack/src/roles/default-function.role.ts">

<violation number="1" location="packages/twenty-apps/public/slack/src/roles/default-function.role.ts:48">
P2: The shared default (fallback) role now gains read access to all CRM objects and workspace members, moving the whole app from 'No CRM data access' to full read of person/company/opportunity/note/task plus workspaceMember. Because this role is the manifest-builder default for every logic function and tool, all of them can now read these records, not just the unfurl logic that needs it. Scope the read to a dedicated role bound to the slack-link-unfurl function (or round-trip through the assistant role) instead of widening the shared default, and add workspaceMember to the description.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

const slackClientResult = await getSlackClient();

if (!slackClientResult.success) {
return { ok: true, skipped: slackClientResult.error };

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When loading the Slack connection fails transiently, this branch acknowledges the link event as skipped and prevents the logic-function runtime from retrying it. Throw the connection error for infrastructure failures; reserve ok: true skips for valid non-record or intentionally disabled inputs.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/twenty-apps/public/slack/src/logic-functions/utils/unfurl-slack-record-links.ts, line 41:

<comment>When loading the Slack connection fails transiently, this branch acknowledges the link event as skipped and prevents the logic-function runtime from retrying it. Throw the connection error for infrastructure failures; reserve `ok: true` skips for valid non-record or intentionally disabled inputs.</comment>

<file context>
@@ -0,0 +1,85 @@
+  const slackClientResult = await getSlackClient();
+
+  if (!slackClientResult.success) {
+    return { ok: true, skipped: slackClientResult.error };
+  }
+
</file context>

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Keeping the ack deliberate here. getSlackConnection folds "Slack is not connected" — a normal, permanent state for installs that never set up the assistant — into the same failure shape as a transient lookup error, so throwing would put every pasted link on unconfigured installs into a retry loop. Unfurls are best-effort decoration and the loss on a rare transient failure is one preview, so the trade-off leans toward the quiet skip. Splitting transient from permanent connection errors would need a change in the shared connection util, which the assistant paths would also want; that's worth doing once, separately.


Generated by Claude Code


console.warn(`[slack] chat.unfurl failed: ${message}`);

return { ok: false, error: message };

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When chat.unfurl fails transiently, this catch returns a successful Promise with an application-level ok: false, so the logic-function runtime has no failure to retry and the record links remain unfurled. Throw the error after logging it so the event execution is retried.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/twenty-apps/public/slack/src/logic-functions/utils/unfurl-slack-record-links.ts, line 81:

<comment>When `chat.unfurl` fails transiently, this catch returns a successful Promise with an application-level `ok: false`, so the logic-function runtime has no failure to retry and the record links remain unfurled. Throw the error after logging it so the event execution is retried.</comment>

<file context>
@@ -0,0 +1,85 @@
+
+    console.warn(`[slack] chat.unfurl failed: ${message}`);
+
+    return { ok: false, error: message };
+  }
+
</file context>

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deliberate as well, for the same reason as the connection branch: chat.unfurl failures skew permanent, not transient — the loudest case is an older install that hasn't re-authorized yet, where every pasted link fails with missing_scope until the admin reconnects. Throwing there would turn each pasted link into a retry loop that can never succeed. The failure is logged and reported in the result (ok: false), and the cost of dropping a genuinely transient failure is one missing preview on one message.


Generated by Claude Code

...READABLE_CRM_OBJECT_UNIVERSAL_IDENTIFIERS.map(
(objectUniversalIdentifier) => ({
objectUniversalIdentifier,
canReadObjectRecords: true,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The shared default (fallback) role now gains read access to all CRM objects and workspace members, moving the whole app from 'No CRM data access' to full read of person/company/opportunity/note/task plus workspaceMember. Because this role is the manifest-builder default for every logic function and tool, all of them can now read these records, not just the unfurl logic that needs it. Scope the read to a dedicated role bound to the slack-link-unfurl function (or round-trip through the assistant role) instead of widening the shared default, and add workspaceMember to the description.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/twenty-apps/public/slack/src/roles/default-function.role.ts, line 48:

<comment>The shared default (fallback) role now gains read access to all CRM objects and workspace members, moving the whole app from 'No CRM data access' to full read of person/company/opportunity/note/task plus workspaceMember. Because this role is the manifest-builder default for every logic function and tool, all of them can now read these records, not just the unfurl logic that needs it. Scope the read to a dedicated role bound to the slack-link-unfurl function (or round-trip through the assistant role) instead of widening the shared default, and add workspaceMember to the description.</comment>

<file context>
@@ -27,6 +42,15 @@ export default defineApplicationRole({
+    ...READABLE_CRM_OBJECT_UNIVERSAL_IDENTIFIERS.map(
+      (objectUniversalIdentifier) => ({
+        objectUniversalIdentifier,
+        canReadObjectRecords: true,
+        canUpdateObjectRecords: false,
+        canSoftDeleteObjectRecords: false,
</file context>

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Took the description part (workspace members are mentioned now, 19ae7c1), but the widening itself is the deliberate trade-off this PR makes, and both suggested alternatives are unavailable today: LogicFunctionManifest has no per-function role field, so a dedicated role cannot be bound to slack-link-unfurl (this is the constraint written down in #24054), and round-tripping through the assistant role means a runAgent call per pasted link, which is the wrong tool for a deterministic single-record read. The widening is read-only, mirrors exactly the object set the assistant role already exposes to anyone who can message the bot, and is called out in SETUP.md. If per-function roles land in the platform, narrowing this back is the natural follow-up.


Generated by Claude Code

Comment thread packages/twenty-apps/public/slack/SETUP.md Outdated
- Skip composer previews explicitly on event.source, not only on the
  missing message timestamp
- Fix the stale step number in the SETUP.md channel welcome note
- Mention workspace member read access in the function role description
@twenty-ci-bot-public

twenty-ci-bot-public Bot commented Aug 13, 2026

Copy link
Copy Markdown

🔍 Automated Pre-Review

No issues detected - This PR is ready for human review.

Summary

  • 🟡 1 other issue(s)

View details

Automated pre-review — human approval still required.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

3 issues found across 16 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="packages/twenty-apps/public/slack/src/logic-functions/utils/build-slack-record-unfurl-attachment.ts">

<violation number="1" location="packages/twenty-apps/public/slack/src/logic-functions/utils/build-slack-record-unfurl-attachment.ts:11">
P3: When a long title or field value contains an astral Unicode character at the truncation boundary, `slice` splits its surrogate pair and corrupts that character. Truncate by code points before appending the ellipsis.</violation>

<violation number="2" location="packages/twenty-apps/public/slack/src/logic-functions/utils/build-slack-record-unfurl-attachment.ts:38">
P2: When a pasted record URL has a long query string, this section still exceeds Slack’s 3,000-character limit because only the title is capped and delimiter encoding can expand the URL. Bound or remove the query portion of the embedded URL before building the section.</violation>
</file>

<file name="packages/twenty-apps/public/slack/src/logic-functions/utils/__tests__/parse-slack-record-link.test.ts">

<violation number="1" location="packages/twenty-apps/public/slack/src/logic-functions/utils/__tests__/parse-slack-record-link.test.ts:40">
P3: The new "should not unescape entities twice" test cannot detect the regression it claims to guard. The entity `&amp;lt;x` is placed in the query string, but `parseSlackRecordLink` only inspects `origin` and `pathname` (then the UUID record id); the decoded query string is never read, and the returned `recordId` comes from the path. So `expect(parsed?.recordId).toBe(RECORD_ID)` passes identically whether `decodeSlackLinkUrl` unescapes once, twice, or not at all. Assert an output the decode actually influences — e.g. put the entity in a path segment and assert the resulting `objectNameSingular`, or expose the decoded URL — otherwise the single-pass guarantee has no test.</violation>
</file>

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

type: 'section',
text: {
type: 'mrkdwn',
text: `*<${encodeSlackLinkUrl(linkUrl)}|${recordTitle}>*`,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When a pasted record URL has a long query string, this section still exceeds Slack’s 3,000-character limit because only the title is capped and delimiter encoding can expand the URL. Bound or remove the query portion of the embedded URL before building the section.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/twenty-apps/public/slack/src/logic-functions/utils/build-slack-record-unfurl-attachment.ts, line 38:

<comment>When a pasted record URL has a long query string, this section still exceeds Slack’s 3,000-character limit because only the title is capped and delimiter encoding can expand the URL. Bound or remove the query portion of the embedded URL before building the section.</comment>

<file context>
@@ -2,22 +2,40 @@ import { type KnownBlock, type MessageAttachment } from '@slack/web-api';
       text: {
         type: 'mrkdwn',
-        text: `*<${linkUrl}|${escapeSlackMrkdwn(card.recordTitle)}>*`,
+        text: `*<${encodeSlackLinkUrl(linkUrl)}|${recordTitle}>*`,
       },
     },
</file context>

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 0271a52 (canonical recordUrl embedded, raw event url kept only as the unfurl key) and 3f42f05 (parameter renamed to make the contract explicit) — see the duplicate thread below for details.


Generated by Claude Code

Comment on lines +11 to +12
const truncateText = (text: string, maxLength: number): string =>
text.length > maxLength ? `${text.slice(0, maxLength - 1)}…` : text;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: When a long title or field value contains an astral Unicode character at the truncation boundary, slice splits its surrogate pair and corrupts that character. Truncate by code points before appending the ellipsis.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/twenty-apps/public/slack/src/logic-functions/utils/build-slack-record-unfurl-attachment.ts, line 11:

<comment>When a long title or field value contains an astral Unicode character at the truncation boundary, `slice` splits its surrogate pair and corrupts that character. Truncate by code points before appending the ellipsis.</comment>

<file context>
@@ -2,22 +2,40 @@ import { type KnownBlock, type MessageAttachment } from '@slack/web-api';
+const TITLE_MAX_LENGTH = 250;
+const FIELD_VALUE_MAX_LENGTH = 350;
+
+const truncateText = (text: string, maxLength: number): string =>
+  text.length > maxLength ? `${text.slice(0, maxLength - 1)}…` : text;
+
</file context>
Suggested change
const truncateText = (text: string, maxLength: number): string =>
text.length > maxLength ? `${text.slice(0, maxLength - 1)}…` : text;
const truncateText = (text: string, maxLength: number): string => {
const characters = Array.from(text);
return characters.length > maxLength
? `${characters.slice(0, maxLength - 1).join('')}…`
: text;
};

workspaceBaseUrl: WORKSPACE_BASE_URL,
});

expect(parsed?.recordId).toBe(RECORD_ID);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The new "should not unescape entities twice" test cannot detect the regression it claims to guard. The entity &amp;lt;x is placed in the query string, but parseSlackRecordLink only inspects origin and pathname (then the UUID record id); the decoded query string is never read, and the returned recordId comes from the path. So expect(parsed?.recordId).toBe(RECORD_ID) passes identically whether decodeSlackLinkUrl unescapes once, twice, or not at all. Assert an output the decode actually influences — e.g. put the entity in a path segment and assert the resulting objectNameSingular, or expose the decoded URL — otherwise the single-pass guarantee has no test.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/twenty-apps/public/slack/src/logic-functions/utils/__tests__/parse-slack-record-link.test.ts, line 40:

<comment>The new "should not unescape entities twice" test cannot detect the regression it claims to guard. The entity `&amp;lt;x` is placed in the query string, but `parseSlackRecordLink` only inspects `origin` and `pathname` (then the UUID record id); the decoded query string is never read, and the returned `recordId` comes from the path. So `expect(parsed?.recordId).toBe(RECORD_ID)` passes identically whether `decodeSlackLinkUrl` unescapes once, twice, or not at all. Assert an output the decode actually influences — e.g. put the entity in a path segment and assert the resulting `objectNameSingular`, or expose the decoded URL — otherwise the single-pass guarantee has no test.</comment>

<file context>
@@ -30,6 +30,16 @@ describe('parseSlackRecordLink', () => {
+      workspaceBaseUrl: WORKSPACE_BASE_URL,
+    });
+
+    expect(parsed?.recordId).toBe(RECORD_ID);
+  });
+
</file context>

… point

- Embed a canonical origin + /object/<name>/<id> url in the card so a
  pasted link's query string cannot push the section past Slack's
  3000-char limit; the raw event url stays the chat.unfurl key
- Truncate titles and field values by code point so surrogate pairs
  survive the boundary
- Move the link url entity decoding into its own util with direct
  tests for the single-pass guarantee

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2 issues found across 16 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="packages/twenty-apps/public/slack/src/logic-functions/utils/__tests__/parse-slack-record-link.test.ts">

<violation number="1" location="packages/twenty-apps/public/slack/src/logic-functions/utils/__tests__/parse-slack-record-link.test.ts:40">
P3: This test does not protect the behavior it describes. `parseSlackRecordLink` only returns the original `linkUrl`, `objectNameSingular`, and `recordId`; none of these reflect the decoded query string, so the double-unescaping regression (`&amp;lt;` → `<`) is unobservable through this test. The assertion `parsed?.recordId` toBe `RECORD_ID` passes whether or not the `&lt;` is preserved, so the test can never fail for the case it claims to cover. To guard the single-decode behavior, assert the decoded output directly (e.g. expose/verify the decoded query retains `&lt;` rather than `<`), or rework the test so it observes actual decoding.</violation>
</file>

<file name="packages/twenty-apps/public/slack/src/logic-functions/utils/build-slack-record-unfurl-attachment.ts">

<violation number="1" location="packages/twenty-apps/public/slack/src/logic-functions/utils/build-slack-record-unfurl-attachment.ts:18">
P2: When an oversized title or field value ends at a surrogate-pair boundary, `truncateText` splits the character and can render `�`. Truncate by code points before appending the ellipsis.</violation>
</file>

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

text: string;
maxLength: number;
}): string =>
text.length > maxLength ? `${text.slice(0, maxLength - 1)}…` : text;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When an oversized title or field value ends at a surrogate-pair boundary, truncateText splits the character and can render . Truncate by code points before appending the ellipsis.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/twenty-apps/public/slack/src/logic-functions/utils/build-slack-record-unfurl-attachment.ts, line 18:

<comment>When an oversized title or field value ends at a surrogate-pair boundary, `truncateText` splits the character and can render `�`. Truncate by code points before appending the ellipsis.</comment>

<file context>
@@ -2,22 +2,46 @@ import { type KnownBlock, type MessageAttachment } from '@slack/web-api';
+  text: string;
+  maxLength: number;
+}): string =>
+  text.length > maxLength ? `${text.slice(0, maxLength - 1)}…` : text;
+
 const escapeSlackMrkdwn = (text: string): string =>
</file context>
Suggested change
text.length > maxLength ? `${text.slice(0, maxLength - 1)}…` : text;
text.length > maxLength
? `${[...text].slice(0, maxLength - 1).join('')}…`
: text;

workspaceBaseUrl: WORKSPACE_BASE_URL,
});

expect(parsed?.recordId).toBe(RECORD_ID);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: This test does not protect the behavior it describes. parseSlackRecordLink only returns the original linkUrl, objectNameSingular, and recordId; none of these reflect the decoded query string, so the double-unescaping regression (&amp;lt;<) is unobservable through this test. The assertion parsed?.recordId toBe RECORD_ID passes whether or not the &lt; is preserved, so the test can never fail for the case it claims to cover. To guard the single-decode behavior, assert the decoded output directly (e.g. expose/verify the decoded query retains &lt; rather than <), or rework the test so it observes actual decoding.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/twenty-apps/public/slack/src/logic-functions/utils/__tests__/parse-slack-record-link.test.ts, line 40:

<comment>This test does not protect the behavior it describes. `parseSlackRecordLink` only returns the original `linkUrl`, `objectNameSingular`, and `recordId`; none of these reflect the decoded query string, so the double-unescaping regression (`&amp;lt;` → `<`) is unobservable through this test. The assertion `parsed?.recordId` toBe `RECORD_ID` passes whether or not the `&lt;` is preserved, so the test can never fail for the case it claims to cover. To guard the single-decode behavior, assert the decoded output directly (e.g. expose/verify the decoded query retains `&lt;` rather than `<`), or rework the test so it observes actual decoding.</comment>

<file context>
@@ -30,6 +30,16 @@ describe('parseSlackRecordLink', () => {
+      workspaceBaseUrl: WORKSPACE_BASE_URL,
+    });
+
+    expect(parsed?.recordId).toBe(RECORD_ID);
+  });
+
</file context>

@twenty-eng-sync twenty-eng-sync Bot closed this Aug 14, 2026
@twenty-eng-sync

Copy link
Copy Markdown

Auto-closed: this PR is draft and has had no update in over 24 hours. Please reopen it once you have the bandwidth to take it forward.

@twenty-eng-sync twenty-eng-sync Bot added the -PR: stale auto-closed Closed by the stale-draft auto-close cron (no update in 24h) label Aug 14, 2026
@abdulrahmancodes abdulrahmancodes removed the -PR: stale auto-closed Closed by the stale-draft auto-close cron (no update in 24h) label Aug 14, 2026
@greptile-apps

greptile-apps Bot commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Want your agent to iterate on Greptile's feedback? Try greploops.

…rdUrl

Makes the builder's contract explicit: it receives the canonical
bounded record url from parseSlackRecordLink, never the raw event url
with its unbounded query string.
@bosiraphael
bosiraphael self-requested a review August 14, 2026 09:40
@twenty-eng-sync twenty-eng-sync Bot closed this Aug 15, 2026
@twenty-eng-sync twenty-eng-sync Bot added the -PR: stale auto-closed Closed by the stale-draft auto-close cron (no update in 24h) label Aug 15, 2026
@twenty-eng-sync

Copy link
Copy Markdown

Auto-closed: this PR is draft and has had no update in over 24 hours. Please reopen it once you have the bandwidth to take it forward.

@abdulrahmancodes abdulrahmancodes removed the -PR: stale auto-closed Closed by the stale-draft auto-close cron (no update in 24h) label Aug 15, 2026
Combines the link unfurl routing with the install-revocation routing
(app_uninstalled / tokens_revoked) and the run-as-workspace-member role
changes from main: both resolver cases kept, the function role keeps
its slackUserLink and workspaceMember grants alongside the read-only
CRM objects for unfurls, and SETUP.md documents both upgrade paths.
Version stays 0.5.0 above main's 0.4.1.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(twenty-slack): unfurl Twenty record links pasted in Slack

2 participants