Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions connectors/slack/src/slack-dm.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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");
});
});
});
7 changes: 7 additions & 0 deletions connectors/slack/src/slack-dm.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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<NewNote, "thread"> & { key: string };
Expand DownExpand Up@@ -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),
Expand Down
25 changes: 24 additions & 1 deletion connectors/slack/src/slack-facets.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -20,11 +20,34 @@ 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",
automation: isBot ? "automated" : "human",
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",
};
}
Loading