diff --git a/connectors/google-chat/package.json b/connectors/google-chat/package.json index c68197fa..f27e5d89 100644 --- a/connectors/google-chat/package.json +++ b/connectors/google-chat/package.json @@ -25,14 +25,16 @@ "build": "tsc", "clean": "rm -rf dist", "deploy": "plot deploy", - "lint": "plot lint" + "lint": "plot lint", + "test": "vitest run" }, "dependencies": { "@plotday/google-contacts": "workspace:^", "@plotday/twister": "workspace:^" }, "devDependencies": { - "typescript": "^5.9.3" + "typescript": "^5.9.3", + "vitest": "^2.1.8" }, "repository": { "type": "git", diff --git a/connectors/google-chat/src/google-chat-api.ts b/connectors/google-chat/src/google-chat-api.ts index 0bfb89ad..db0a8dba 100644 --- a/connectors/google-chat/src/google-chat-api.ts +++ b/connectors/google-chat/src/google-chat-api.ts @@ -5,6 +5,11 @@ import type { NewReactions, } from "@plotday/twister/plot"; +import { + googleChatDmFacets, + googleChatThreadFacets, +} from "./google-chat-facets"; + // ---- Google Chat API types ---- export type Space = { @@ -638,11 +643,16 @@ function formatAttachments(attachments: Attachment[] | undefined): string { /** * Transforms a group of Google Chat messages (one thread) into a NewLinkWithNotes. + * + * `kind` distinguishes named spaces from DM/group-chat spaces so the link can + * carry classifier facets: a named space is broadcast context (`reach: list`), + * while a DM addresses the user (`reach: direct`). */ export function transformChatThread( messages: Message[], spaceId: string, initialSync: boolean, + kind: "space" | "dm", memberInfo?: Map, members?: NewActor[], reactions?: EmojiReaction[] @@ -665,6 +675,10 @@ export function transformChatThread( accessContacts: members?.filter((m): m is NewContact => !("id" in m)) ?? [], created: new Date(firstMessage.createTime), author: senderToNewActor(firstMessage.sender, memberInfo), + facets: + kind === "dm" + ? googleChatDmFacets(messages) + : googleChatThreadFacets(firstMessage), sourceUrl: `https://chat.google.com/room/${spaceId}/${threadKey}`, meta: { spaceId, diff --git a/connectors/google-chat/src/google-chat-facets.test.ts b/connectors/google-chat/src/google-chat-facets.test.ts new file mode 100644 index 00000000..b709f100 --- /dev/null +++ b/connectors/google-chat/src/google-chat-facets.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, it } from "vitest"; + +import type { Message, MessageSender } from "./google-chat-api"; +import { transformChatThread } from "./google-chat-api"; +import { + googleChatDmFacets, + googleChatThreadFacets, +} from "./google-chat-facets"; + +let counter = 0; + +function chatMessage(overrides: { + text?: string; + sender?: Partial; +} = {}): Message { + counter += 1; + return { + name: `spaces/SPACE1/messages/msg${counter}`, + sender: { + name: `users/${100 + counter}`, + displayName: "Ada Lovelace", + type: "HUMAN", + ...overrides.sender, + } as MessageSender, + createTime: "2026-08-07T12:00:00Z", + text: overrides.text ?? "hello there", + thread: { name: "spaces/SPACE1/threads/thread1" }, + space: { name: "spaces/SPACE1" }, + }; +} + +describe("googleChatThreadFacets", () => { + it("classifies a short human parent as a direct-format chat in a list context", () => { + const facets = googleChatThreadFacets(chatMessage()); + expect(facets).toEqual({ + format: "chat", + automation: "human", + reach: "list", + }); + }); + + it("classifies a bot-sent parent as automated", () => { + const facets = googleChatThreadFacets( + chatMessage({ sender: { type: "BOT", displayName: "Build Bot" } }) + ); + expect(facets.automation).toBe("automated"); + }); + + it("treats a long parent as a message rather than a chat", () => { + const facets = googleChatThreadFacets( + chatMessage({ text: "a".repeat(1001) }) + ); + expect(facets.format).toBe("message"); + }); + + it("falls open to human when the sender type is missing", () => { + const message = chatMessage(); + delete (message.sender as Partial).type; + expect(googleChatThreadFacets(message).automation).toBe("human"); + }); +}); + +describe("googleChatDmFacets", () => { + it("classifies a human conversation as a direct human chat", () => { + const facets = googleChatDmFacets([chatMessage(), chatMessage()]); + expect(facets).toEqual({ + format: "chat", + automation: "human", + reach: "direct", + }); + }); + + it("classifies an all-bot conversation as automated", () => { + const bot = { sender: { type: "BOT" as const, displayName: "Reminder Bot" } }; + const facets = googleChatDmFacets([chatMessage(bot), chatMessage(bot)]); + expect(facets.automation).toBe("automated"); + }); + + it("stays human when even one message is human-shaped", () => { + const bot = { sender: { type: "BOT" as const, displayName: "Reminder Bot" } }; + const facets = googleChatDmFacets([ + chatMessage(bot), + chatMessage(), + chatMessage(bot), + ]); + expect(facets.automation).toBe("human"); + }); + + it("falls open to human for an empty batch", () => { + expect(googleChatDmFacets([]).automation).toBe("human"); + }); + + it("keeps chat format even for a long message", () => { + const facets = googleChatDmFacets([chatMessage({ text: "a".repeat(5000) })]); + expect(facets.format).toBe("chat"); + }); +}); + +describe("transformChatThread facet stamping", () => { + it("stamps list-reach facets on a named-space thread", () => { + const link = transformChatThread([chatMessage()], "SPACE1", true, "space"); + expect(link.facets).toEqual({ + format: "chat", + automation: "human", + reach: "list", + }); + }); + + it("stamps direct-reach facets on a DM thread", () => { + const link = transformChatThread([chatMessage()], "DM1", true, "dm"); + expect(link.facets).toEqual({ + format: "chat", + automation: "human", + reach: "direct", + }); + }); + + it("judges a DM batch as a whole, not just the parent", () => { + const bot = { sender: { type: "BOT" as const, displayName: "Reminder Bot" } }; + const link = transformChatThread( + [chatMessage(bot), chatMessage()], + "DM1", + true, + "dm" + ); + expect(link.facets?.automation).toBe("human"); + }); +}); diff --git a/connectors/google-chat/src/google-chat-facets.ts b/connectors/google-chat/src/google-chat-facets.ts new file mode 100644 index 00000000..3e5dce18 --- /dev/null +++ b/connectors/google-chat/src/google-chat-facets.ts @@ -0,0 +1,45 @@ +import type { ThreadFacets } from "@plotday/twister/facets"; + +import type { Message } from "./google-chat-api"; + +// A long Google Chat post reads as a "message" rather than a quick "chat". +const CHAT_MAX_LENGTH = 1000; + +// Google Chat senders carry an explicit type; only an explicit BOT counts as +// bot-shaped. A missing sender or type falls open to human — a muteable +// `automated` verdict that hides a real person is the one failure +// classification must never produce. +function isBotMessage(message: Message): boolean { + return message.sender?.type === "BOT"; +} + +/** + * Facets for a thread in a named space. Named spaces are broadcast context, + * so `reach` is `list`; format and automation are judged from the thread's + * parent message, best-effort per the facet design's fail-open principle. + */ +export function googleChatThreadFacets(parent: Message): ThreadFacets { + const text = parent.text ?? ""; + return { + format: text.length > CHAT_MAX_LENGTH ? "message" : "chat", + automation: isBotMessage(parent) ? "automated" : "human", + reach: "list", + }; +} + +/** + * Facets for a thread in a DM or group chat. + * + * `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 single human-shaped message makes the conversation `human`. + */ +export function googleChatDmFacets(messages: Message[]): ThreadFacets { + const isBot = messages.length > 0 && messages.every(isBotMessage); + return { + format: "chat", + automation: isBot ? "automated" : "human", + reach: "direct", + }; +} diff --git a/connectors/google-chat/src/google-chat.ts b/connectors/google-chat/src/google-chat.ts index f0d316e3..ed29575b 100644 --- a/connectors/google-chat/src/google-chat.ts +++ b/connectors/google-chat/src/google-chat.ts @@ -432,6 +432,7 @@ export class GoogleChat extends Connector { filtered, spaceId, initialSync, + "space", memberInfo, members ); @@ -626,6 +627,7 @@ export class GoogleChat extends Connector { threadMessages, spaceId, isInitial, + "dm", memberInfo, members ); @@ -1017,6 +1019,7 @@ export class GoogleChat extends Connector { [message], spaceId, false, + "space", memberInfo, members, reactions diff --git a/connectors/google-chat/vitest.config.ts b/connectors/google-chat/vitest.config.ts new file mode 100644 index 00000000..9102ff68 --- /dev/null +++ b/connectors/google-chat/vitest.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + resolve: { + // Resolve workspace connector packages from their TypeScript source + // using the @plotday/connector export condition (same as the build path). + conditions: ["@plotday/connector", "default"], + }, + test: {}, +}); diff --git a/connectors/ms-teams/src/graph-api.ts b/connectors/ms-teams/src/graph-api.ts index 3de5b259..a478cac0 100644 --- a/connectors/ms-teams/src/graph-api.ts +++ b/connectors/ms-teams/src/graph-api.ts @@ -1,3 +1,4 @@ +import type { ThreadFacets } from "@plotday/twister/facets"; import type { NewActor, NewContact, @@ -616,6 +617,51 @@ function stripHtml(html: string): string { .trim(); } +// A long Teams post reads as a "message" rather than a quick "chat". +const CHAT_MAX_LENGTH = 1000; + +// A Teams message is bot-shaped when an application sent it, or when it has +// no human sender at all (system events). Anything with a human sender is +// human — a muteable `automated` verdict that hides a real person is the one +// failure classification must never produce. +function isBotShaped(message: TeamsMessage): boolean { + return Boolean(message.from?.application) || !message.from?.user; +} + +/** + * Facets for a channel message thread. Channels are broadcast context, so + * `reach` is `list`; format and automation are judged from the thread's + * parent message, best-effort per the facet design's fail-open principle. + */ +export function teamsChannelFacets(parent: TeamsMessage): ThreadFacets { + const text = stripHtml(parent.body.content); + return { + format: text.length > CHAT_MAX_LENGTH ? "message" : "chat", + automation: isBotShaped(parent) ? "automated" : "human", + reach: "list", + }; +} + +/** + * Facets for a direct or group chat. + * + * `reach` is `direct` by construction (a chat addresses the user), and the + * conversation reads as `chat` regardless of any one message's length. For + * `automation` the user-visible messages are judged as a whole, and only an + * all-bot batch is `automated`: a single human-shaped message makes the + * conversation `human`. System event messages are excluded from the + * judgement, mirroring their exclusion from the notes. + */ +export function teamsDmFacets(messages: TeamsMessage[]): ThreadFacets { + const visible = messages.filter((msg) => msg.messageType === "message"); + const isBot = visible.length > 0 && visible.every(isBotShaped); + return { + format: "chat", + automation: isBot ? "automated" : "human", + reach: "direct", + }; +} + /** * Transforms a Teams channel message thread (parent + replies) into a * NewLinkWithNotes structure for saving via integrations.saveLink(). @@ -640,6 +686,7 @@ export function transformChannelThread( title, created: new Date(parentMessage.createdDateTime), author: userToNewActor(parentMessage.from?.user), + facets: teamsChannelFacets(parentMessage), preview: stripHtml(parentMessage.body.content) || null, meta: { teamId, @@ -689,6 +736,7 @@ export function transformDmThread( title: "Empty chat", access: "private", accessContacts, + facets: teamsDmFacets([]), notes: [], }; } @@ -705,6 +753,7 @@ export function transformDmThread( accessContacts, created: new Date(firstMessage.createdDateTime), author: userToNewActor(firstMessage.from?.user), + facets: teamsDmFacets(messages), preview: stripHtml(firstMessage.body.content) || null, meta: { chatId, diff --git a/connectors/ms-teams/src/ms-teams-facets.test.ts b/connectors/ms-teams/src/ms-teams-facets.test.ts new file mode 100644 index 00000000..8ea67c7c --- /dev/null +++ b/connectors/ms-teams/src/ms-teams-facets.test.ts @@ -0,0 +1,144 @@ +import { describe, expect, it } from "vitest"; + +import type { TeamsMessage } from "./graph-api"; +import { + teamsChannelFacets, + teamsDmFacets, + transformChannelThread, + transformDmThread, +} from "./graph-api"; + +let counter = 0; + +function teamsMessage(overrides: Partial = {}): TeamsMessage { + counter += 1; + return { + id: `msg${counter}`, + createdDateTime: "2026-08-07T12:00:00Z", + messageType: "message", + from: { user: { id: `user${counter}`, displayName: "Ada Lovelace" } }, + body: { contentType: "html", content: "

hello there

" }, + ...overrides, + }; +} + +function appMessage(overrides: Partial = {}): TeamsMessage { + return teamsMessage({ + from: { application: { id: "app1", displayName: "Build Bot" } }, + ...overrides, + }); +} + +describe("teamsChannelFacets", () => { + it("classifies a short human parent as a chat in a list context", () => { + expect(teamsChannelFacets(teamsMessage())).toEqual({ + format: "chat", + automation: "human", + reach: "list", + }); + }); + + it("classifies an application-sent parent as automated", () => { + expect(teamsChannelFacets(appMessage()).automation).toBe("automated"); + }); + + it("classifies a sender-less parent as automated", () => { + const message = teamsMessage(); + delete message.from; + expect(teamsChannelFacets(message).automation).toBe("automated"); + }); + + it("treats a long parent as a message rather than a chat", () => { + const message = teamsMessage({ + body: { contentType: "html", content: `

${"a".repeat(1001)}

` }, + }); + expect(teamsChannelFacets(message).format).toBe("message"); + }); + + it("measures length on the stripped text, not the raw HTML", () => { + const message = teamsMessage({ + body: { + contentType: "html", + content: `
short
`, + }, + }); + expect(teamsChannelFacets(message).format).toBe("chat"); + }); +}); + +describe("teamsDmFacets", () => { + it("classifies a human conversation as a direct human chat", () => { + expect(teamsDmFacets([teamsMessage(), teamsMessage()])).toEqual({ + format: "chat", + automation: "human", + reach: "direct", + }); + }); + + it("classifies an all-application conversation as automated", () => { + expect(teamsDmFacets([appMessage(), appMessage()]).automation).toBe( + "automated" + ); + }); + + it("stays human when even one message is human-shaped", () => { + expect( + teamsDmFacets([appMessage(), teamsMessage(), appMessage()]).automation + ).toBe("human"); + }); + + it("falls open to human for an empty batch", () => { + expect(teamsDmFacets([]).automation).toBe("human"); + }); + + it("ignores system event messages when judging automation", () => { + const system = teamsMessage({ messageType: "systemEventMessage" }); + delete system.from; + expect(teamsDmFacets([system]).automation).toBe("human"); + }); +}); + +describe("transform facet stamping", () => { + it("stamps list-reach facets on a channel thread", () => { + const link = transformChannelThread( + teamsMessage(), + [], + "team1", + "channel1", + true + ); + expect(link.facets).toEqual({ + format: "chat", + automation: "human", + reach: "list", + }); + }); + + it("stamps direct-reach facets on a DM thread", () => { + const link = transformDmThread([teamsMessage()], "chat1", [], true); + expect(link.facets).toEqual({ + format: "chat", + automation: "human", + reach: "direct", + }); + }); + + it("stamps fail-open facets on an empty DM chat", () => { + const link = transformDmThread([], "chat1", [], true); + expect(link.facets).toEqual({ + format: "chat", + automation: "human", + reach: "direct", + }); + }); + + it("judges a DM batch as a whole, not just the first message", () => { + const link = transformDmThread( + [appMessage(), teamsMessage()], + "chat1", + [], + true + ); + expect(link.facets?.automation).toBe("human"); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 461e3c85..757ca0f4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -139,6 +139,9 @@ importers: typescript: specifier: ^5.9.3 version: 5.9.3 + vitest: + specifier: ^2.1.8 + version: 2.1.9(@types/node@25.0.3) connectors/google-drive: dependencies: