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
6 changes: 4 additions & 2 deletions connectors/google-chat/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -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",
Expand Down
14 changes: 14 additions & 0 deletions connectors/google-chat/src/google-chat-api.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 = {
Expand DownExpand Up@@ -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<string, MemberInfo>,
members?: NewActor[],
reactions?: EmojiReaction[]
Expand All@@ -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,
Expand Down
128 changes: 128 additions & 0 deletions connectors/google-chat/src/google-chat-facets.test.ts
Original file line numberDiff line numberDiff line change
@@ -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<MessageSender>;
} = {}): 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<MessageSender>).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");
});
});
45 changes: 45 additions & 0 deletions connectors/google-chat/src/google-chat-facets.ts
Original file line numberDiff line numberDiff line change
@@ -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",
};
}
3 changes: 3 additions & 0 deletions connectors/google-chat/src/google-chat.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -432,6 +432,7 @@ export class GoogleChat extends Connector<GoogleChat> {
filtered,
spaceId,
initialSync,
"space",
memberInfo,
members
);
Expand DownExpand Up@@ -626,6 +627,7 @@ export class GoogleChat extends Connector<GoogleChat> {
threadMessages,
spaceId,
isInitial,
"dm",
memberInfo,
members
);
Expand DownExpand Up@@ -1017,6 +1019,7 @@ export class GoogleChat extends Connector<GoogleChat> {
[message],
spaceId,
false,
"space",
memberInfo,
members,
reactions
Expand Down
10 changes: 10 additions & 0 deletions connectors/google-chat/vitest.config.ts
Original file line numberDiff line numberDiff line change
@@ -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: {},
});
49 changes: 49 additions & 0 deletions connectors/ms-teams/src/graph-api.ts
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
import type { ThreadFacets } from "@plotday/twister/facets";
import type {
NewActor,
NewContact,
Expand DownExpand Up@@ -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().
Expand All@@ -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,
Expand DownExpand Up@@ -689,6 +736,7 @@ export function transformDmThread(
title: "Empty chat",
access: "private",
accessContacts,
facets: teamsDmFacets([]),
notes: [],
};
}
Expand All@@ -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,
Expand Down
Loading
Loading