Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
5 changes: 5 additions & 0 deletions .changeset/thread-get-participants.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"chat": minor
---

Add `thread.getParticipants()` to get unique human participants in a thread
152 changes: 152 additions & 0 deletions packages/chat/src/thread.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2711,4 +2711,156 @@ describe("ThreadImpl", () => {
expect(cancel2).not.toHaveBeenCalled();
});
});

describe("getParticipants", () => {
it("should return unique non-bot authors from messages", async () => {
const mockAdapter = createMockAdapter();
const mockState = createMockState();

const msg1 = createTestMessage("1", "Hello", {
author: {
userId: "U1",
userName: "alice",
fullName: "Alice",
isBot: false,
isMe: false,
},
});
const msg2 = createTestMessage("2", "Hi", {
author: {
userId: "U2",
userName: "bob",
fullName: "Bob",
isBot: false,
isMe: false,
},
});
const msg3 = createTestMessage("3", "Hello again", {
author: {
userId: "U1",
userName: "alice",
fullName: "Alice",
isBot: false,
isMe: false,
},
});

mockAdapter.fetchMessages = vi
.fn()
.mockResolvedValue({ messages: [msg1, msg2, msg3], nextCursor: null });

const thread = new ThreadImpl({
id: "slack:C123:1234.5678",
adapter: mockAdapter,
channelId: "C123",
stateAdapter: mockState,
});

const participants = await thread.getParticipants();
expect(participants).toHaveLength(2);
expect(participants.map((p) => p.userId)).toEqual(
expect.arrayContaining(["U1", "U2"])
);
});

it("should exclude bot messages", async () => {
const mockAdapter = createMockAdapter();
const mockState = createMockState();

const humanMsg = createTestMessage("1", "Hello", {
author: {
userId: "U1",
userName: "alice",
fullName: "Alice",
isBot: false,
isMe: false,
},
});
const botMsg = createTestMessage("2", "Hi there!", {
author: {
userId: "B1",
userName: "bot",
fullName: "Bot",
isBot: true,
isMe: true,
},
});

mockAdapter.fetchMessages = vi
.fn()
.mockResolvedValue({ messages: [humanMsg, botMsg], nextCursor: null });

const thread = new ThreadImpl({
id: "slack:C123:1234.5678",
adapter: mockAdapter,
channelId: "C123",
stateAdapter: mockState,
});

const participants = await thread.getParticipants();
expect(participants).toHaveLength(1);
expect(participants[0].userId).toBe("U1");
});

it("should return empty array for thread with only bot messages", async () => {
const mockAdapter = createMockAdapter();
const mockState = createMockState();

mockAdapter.fetchMessages = vi.fn().mockResolvedValue({
messages: [
createTestMessage("1", "Bot message", {
author: {
userId: "B1",
userName: "bot",
fullName: "Bot",
isBot: true,
isMe: true,
},
}),
],
nextCursor: null,
});

const thread = new ThreadImpl({
id: "slack:C123:1234.5678",
adapter: mockAdapter,
channelId: "C123",
stateAdapter: mockState,
});

const participants = await thread.getParticipants();
expect(participants).toHaveLength(0);
});

it("should include currentMessage author", async () => {
const mockAdapter = createMockAdapter();
const mockState = createMockState();

const currentMsg = createTestMessage("1", "Hey bot", {
author: {
userId: "U1",
userName: "alice",
fullName: "Alice",
isBot: false,
isMe: false,
},
});

mockAdapter.fetchMessages = vi
.fn()
.mockResolvedValue({ messages: [], nextCursor: null });

const thread = new ThreadImpl({
id: "slack:C123:1234.5678",
adapter: mockAdapter,
channelId: "C123",
stateAdapter: mockState,
currentMessage: currentMsg,
});

const participants = await thread.getParticipants();
expect(participants).toHaveLength(1);
expect(participants[0].userId).toBe("U1");
});
});
});
19 changes: 19 additions & 0 deletions packages/chat/src/thread.ts
Original file line number Diff line number Diff line change
Expand Up @@ -356,6 +356,25 @@ export class ThreadImpl<TState = Record<string, unknown>>
};
}

async getParticipants(): Promise<Author[]> {
const seen = new Map<string, Author>();

// Include the current message author if available
if (this._currentMessage && !this._currentMessage.author.isMe) {
seen.set(this._currentMessage.author.userId, this._currentMessage.author);
}

// Scan all messages for unique non-bot authors
Comment thread
bensabic marked this conversation as resolved.
Outdated
for await (const message of this.allMessages) {
if (message.author.isMe || seen.has(message.author.userId)) {
continue;
}
seen.set(message.author.userId, message.author);
}

return [...seen.values()];
}

async isSubscribed(): Promise<boolean> {
// Short-circuit if we know we're in a subscribed context
if (this._isSubscribedContext) {
Expand Down
35 changes: 35 additions & 0 deletions packages/chat/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1025,6 +1025,41 @@ export interface Thread<TState = Record<string, unknown>, TRawMessage = unknown>
message: Message<TRawMessage>
): SentMessage<TRawMessage>;

/**
* Get the unique human participants in this thread.
*
* Scans all messages in the thread and returns deduplicated authors,
* excluding the bot itself. Useful for deciding whether to subscribe
* based on how many humans are participating — subscribe when it's a
* 1:1 conversation, unsubscribe when others join so humans can talk
* without the bot replying to every message.
*
* @returns Array of unique non-bot authors
*
* @example
* ```typescript
* // Subscribe only when one person is talking to the bot
* bot.onNewMention(async (thread, message) => {
* const participants = await thread.getParticipants();
* if (participants.length === 1) {
* await thread.subscribe();
* await thread.post("I'm here to help!");
* }
* });
*
* // Unsubscribe when the thread becomes a group conversation
* bot.onSubscribedMessage(async (thread, message) => {
* const participants = await thread.getParticipants();
* if (participants.length > 1) {
* await thread.unsubscribe();
* return;
* }
* await thread.post("Still here to help!");
* });
* ```
*/
getParticipants(): Promise<Author[]>;

/**
* Check if this thread is currently subscribed.
*
Expand Down
Loading