diff --git a/connectors/slack/src/slack-dm.test.ts b/connectors/slack/src/slack-dm.test.ts index 8e8d9884..963278ba 100644 --- a/connectors/slack/src/slack-dm.test.ts +++ b/connectors/slack/src/slack-dm.test.ts @@ -236,4 +236,64 @@ describe("assembleSlackDmLink", () => { "https://slack.com/app_redirect?channel=D111&message_ts=102.0" ); }); + + describe("facets", () => { + it("marks a human 1:1 DM chat / human / direct", () => { + const link = assembleSlackDmLink({ + channelId: "D111", + counterpartyUserId: "U222", + messages: [msg("100.0", "U222", "hi")], + initialSync: false, + }); + + expect(link?.facets).toEqual({ + format: "chat", + automation: "human", + reach: "direct", + }); + }); + + it("marks a group DM chat / human / direct", () => { + const link = assembleSlackDmLink({ + channelId: "G333", + counterpartyUserId: null, + messages: [msg("100.0", "U222", "hi all")], + initialSync: false, + }); + + expect(link?.facets).toEqual({ + format: "chat", + automation: "human", + reach: "direct", + }); + }); + + it("marks a conversation automated only when every message is bot-shaped", () => { + const link = assembleSlackDmLink({ + channelId: "D111", + counterpartyUserId: "USLACKBOT", + messages: [ + { type: "message", ts: "100.0", bot_id: "B1", text: "reminder" }, + { type: "message", ts: "101.0", subtype: "bot_message", text: "digest" }, + ], + initialSync: false, + }); + + expect(link?.facets?.automation).toBe("automated"); + }); + + it("one human message makes the whole conversation human (fail-open)", () => { + const link = assembleSlackDmLink({ + channelId: "D111", + counterpartyUserId: "U222", + messages: [ + { type: "message", ts: "100.0", bot_id: "B1", text: "automated ping" }, + msg("101.0", "U222", "actually a person here"), + ], + initialSync: false, + }); + + expect(link?.facets?.automation).toBe("human"); + }); + }); }); diff --git a/connectors/slack/src/slack-dm.ts b/connectors/slack/src/slack-dm.ts index 065d21ce..9b957978 100644 --- a/connectors/slack/src/slack-dm.ts +++ b/connectors/slack/src/slack-dm.ts @@ -13,6 +13,7 @@ import { type SlackMessage, type SlackUserInfoMap, } from "./slack-api"; +import { slackDmFacets } from "./slack-facets"; /** A DM link note, checked against the SDK's `NewNote` shape, with a guaranteed `key`. */ export type SlackDmNote = Omit & { key: string }; @@ -185,6 +186,12 @@ export function assembleSlackDmLink( sources, channelId, type: "dm", + // Classifier facets: a DM is chat, addressed directly to the user, and + // human unless the whole batch is bot-shaped (see slackDmFacets). The + // channel-thread path sets facets in buildConversationLink; without this + // line direct conversations — the bulk of what this connector syncs — + // carry none at all. + facets: slackDmFacets(ordered), ...(title ? { title } : {}), preview: formatSlackText(last.text ?? ""), created: new Date(parseFloat(ordered[0]!.ts) * 1000), diff --git a/connectors/slack/src/slack-facets.ts b/connectors/slack/src/slack-facets.ts index 3a816723..fc94810d 100644 --- a/connectors/slack/src/slack-facets.ts +++ b/connectors/slack/src/slack-facets.ts @@ -20,7 +20,7 @@ const CHAT_MAX_LENGTH = 1000; * time); this is best-effort per the facet design's fail-open principle. */ export function slackFacets(parent: SlackMessage, channelId: string): ThreadFacets { - const isBot = Boolean(parent.bot_id) || parent.subtype === "bot_message" || !parent.user; + const isBot = isBotShaped(parent); const text = parent.text ?? ""; return { format: text.length > CHAT_MAX_LENGTH ? "message" : "chat", @@ -28,3 +28,26 @@ export function slackFacets(parent: SlackMessage, channelId: string): ThreadFace reach: reachForChannel(channelId), }; } + +function isBotShaped(message: SlackMessage): boolean { + return Boolean(message.bot_id) || message.subtype === "bot_message" || !message.user; +} + +/** + * Facets for the single permanent link of a direct or group conversation. + * + * `reach` is `direct` by construction (a DM addresses the user), and the + * conversation reads as `chat` regardless of any one message's length. For + * `automation` the batch is judged as a whole, and only an all-bot batch is + * `automated`: a muteable `automated` verdict that hides a real person is the + * one failure classification must never produce, so a single human-shaped + * message makes the conversation `human`. + */ +export function slackDmFacets(messages: SlackMessage[]): ThreadFacets { + const isBot = messages.length > 0 && messages.every(isBotShaped); + return { + format: "chat", + automation: isBot ? "automated" : "human", + reach: "direct", + }; +}