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: 6 additions & 0 deletions .changeset/calm-teams-conversations.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@chat-adapter/teams": patch
"chat": patch
---

Route Teams personal and group conversations using their explicit conversation type so group chats use buffered fallback even when their IDs resemble direct messages.
4 changes: 4 additions & 0 deletions apps/docs/content/adapters/official/teams.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,10 @@ The Teams SDK reads a generic `CLIENT_SECRET` environment variable and prefers i

## Advanced

### Conversation routing

Incoming thread IDs preserve the Teams conversation type when the legacy ID-prefix heuristic would route it incorrectly. This keeps correctly classified IDs stable while selecting the buffered fallback for group chats whose IDs begin with `a:`. Thread IDs created by older adapter versions remain supported.

### User lookup

The adapter supports looking up user profiles via the Microsoft Graph API. To enable it:
Expand Down
2 changes: 1 addition & 1 deletion apps/docs/content/docs/api/channel.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ Channel IDs are derived from thread IDs by dropping the thread-specific part. By
| Platform | Thread ID | Channel ID |
|----------|-----------|------------|
| Slack | `slack:C123ABC:1234567890.123456` | `slack:C123ABC` |
| Teams | `teams:{base64}:{base64}` | `teams:{base64}` |
| Teams | `teams:{base64(conversationId)}:{base64(serviceUrl)}[:{conversationType}]` | `teams:{base64(conversationId)}:{base64(serviceUrl)}[:{conversationType}]` |
| Google Chat | `gchat:spaces/ABC123:{base64}` | `gchat:spaces/ABC123` |
| Discord | `discord:{guildId}:{channelId}/{messageId}` | `discord:{guildId}` |

Expand Down
2 changes: 1 addition & 1 deletion apps/docs/content/docs/threads-messages-channels.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -256,7 +256,7 @@ console.log(info.name, info.memberCount);
All thread IDs follow the pattern `{adapter}:{channel}:{thread}`:

- **Slack**: `slack:C123ABC:1234567890.123456`
- **Teams**: `teams:{base64(conversationId)}:{base64(serviceUrl)}`
- **Teams**: `teams:{base64(conversationId)}:{base64(serviceUrl)}[:{conversationType}]`
- **Google Chat**: `gchat:spaces/ABC123:{base64(threadName)}`
- **Discord**: `discord:{guildId}:{channelId}/{messageId}`

Expand Down
9 changes: 6 additions & 3 deletions packages/adapter-teams/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,13 +130,16 @@ Teams conversation IDs contain colons (e.g.
them before assembling the Chat SDK identifier:

```
teams:{conversationId_base64url}
teams:{conversationId_base64url}:{rootMessageId} # threaded reply
teams:{conversationId_base64url}:{serviceUrl_base64url} # legacy
teams:{conversationId_base64url}:{serviceUrl_base64url}:{conversationType} # classification override
```

`encodeThreadId` / `decodeThreadId` are the only sanctioned way to
construct these — never `string.replace(":", …)` your way around the
encoding.
encoding. The optional conversation type is added only when `personal`,
`groupChat`, or `channel` disagrees with the legacy conversation-ID
prefix heuristic, so existing IDs stay stable unless that heuristic
would route them incorrectly.

`isDM(threadId)` returns `true` when the underlying conversation type
is `personal` (1:1 with the bot) — useful inside handlers that need to
Expand Down
4 changes: 4 additions & 0 deletions packages/adapter-teams/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,10 @@ TEAMS_API_URL=... # Optional, for GCC-High or sovereign-cloud deployments
| Fetch channel info | Yes (requires Graph permissions) |
| Post channel message | Yes |

## Conversation routing

Incoming thread IDs preserve the Teams conversation type when the legacy ID-prefix heuristic would route it incorrectly. This keeps correctly classified IDs stable while selecting the buffered fallback for group chats whose IDs begin with `a:`. Thread IDs created by older adapter versions remain supported.

## User lookup (`getUser`)

The adapter supports looking up user profiles via the Microsoft Graph API. To enable it:
Expand Down
46 changes: 45 additions & 1 deletion packages/adapter-teams/src/graph-api.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { Client as GraphClient } from "@microsoft/teams.graph";
import { ConsoleLogger } from "chat";
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import { TeamsGraphReader } from "./graph-api";
import { TeamsFormatConverter } from "./markdown";
import { decodeThreadId, encodeThreadId, isDM } from "./thread-id";

function createTestReader(): TeamsGraphReader {
return new TeamsGraphReader({
Expand Down Expand Up @@ -183,3 +184,46 @@ describe("chatIdFromContext", () => {
expect(result).toBe("19:channel@thread.tacv2");
});
});

describe("listThreads", () => {
it("preserves an explicit group-chat conversation type", async () => {
const graph = {
call: vi.fn(async () => ({
value: [
{
id: "message-1",
body: { content: "Hello", contentType: "text" },
},
],
})),
};
const reader = new TeamsGraphReader({
botId: "test-app",
graph: graph as unknown as GraphClient,
formatConverter: new TeamsFormatConverter(),
getGraphContext: async () => ({
type: "dm",
graphChatId: "19:stale-personal-chat@unq.gbl.spaces",
}),
logger: new ConsoleLogger("error"),
});
const channelId = encodeThreadId({
conversationId: "a:group-chat-id",
conversationType: "groupChat",
serviceUrl: "https://smba.trafficmanager.net/teams/",
});

const result = await reader.listThreads(channelId);
const threadId = result.threads[0]?.id;

expect(graph.call).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ "chat-id": "a:group-chat-id" })
);
expect(threadId).toBeDefined();
expect(decodeThreadId(threadId as string).conversationType).toBe(
"groupChat"
);
expect(isDM(threadId as string)).toBe(false);
});
});
50 changes: 41 additions & 9 deletions packages/adapter-teams/src/graph-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,11 @@ import type {
import { Message, NotImplementedError } from "chat";
import type { TeamsFormatConverter } from "./markdown";
import { decodeThreadId, encodeThreadId, isDM } from "./thread-id";
import type { TeamsChannelContext, TeamsGraphContext } from "./types";
import type {
TeamsChannelContext,
TeamsGraphContext,
TeamsThreadId,
} from "./types";

const MESSAGEID_STRIP_PATTERN = /;messageid=\d+/;
const SEMICOLON_MESSAGEID_CAPTURE_PATTERN = /;messageid=(\d+)/;
Expand Down Expand Up @@ -62,11 +66,21 @@ export class TeamsGraphReader {
return baseConversationId;
}

private async getGraphContext(
baseConversationId: string,
conversationType: TeamsThreadId["conversationType"]
): Promise<TeamsGraphContext | null> {
if (conversationType === "groupChat") {
return null;
}
return this.deps.getGraphContext(baseConversationId);
}

async fetchMessages(
threadId: string,
options: FetchOptions = {}
): Promise<FetchResult<unknown>> {
const { conversationId } = decodeThreadId(threadId);
const { conversationId, conversationType } = decodeThreadId(threadId);
const limit = options.limit || 50;
const cursor = options.cursor;
const direction = options.direction ?? "backward";
Expand All @@ -80,7 +94,10 @@ export class TeamsGraphReader {
""
);

const graphContext = await this.deps.getGraphContext(baseConversationId);
const graphContext = await this.getGraphContext(
baseConversationId,
conversationType
);

try {
this.deps.logger.debug("Teams Graph API: fetching messages", {
Expand Down Expand Up @@ -180,7 +197,7 @@ export class TeamsGraphReader {
channelId: string,
options: FetchOptions = {}
): Promise<FetchResult<unknown>> {
const { conversationId } = decodeThreadId(channelId);
const { conversationId, conversationType } = decodeThreadId(channelId);
const baseConversationId = conversationId.replace(
MESSAGEID_STRIP_PATTERN,
""
Expand All @@ -189,7 +206,10 @@ export class TeamsGraphReader {
const direction = options.direction ?? "backward";

try {
const graphContext = await this.deps.getGraphContext(baseConversationId);
const graphContext = await this.getGraphContext(
baseConversationId,
conversationType
);

this.deps.logger.debug("Teams Graph API: fetchChannelMessages", {
conversationId: baseConversationId,
Expand Down Expand Up @@ -307,13 +327,16 @@ export class TeamsGraphReader {
}

async fetchChannelInfo(channelId: string): Promise<ChannelInfo> {
const { conversationId } = decodeThreadId(channelId);
const { conversationId, conversationType } = decodeThreadId(channelId);
const baseConversationId = conversationId.replace(
MESSAGEID_STRIP_PATTERN,
""
);

const graphContext = await this.deps.getGraphContext(baseConversationId);
const graphContext = await this.getGraphContext(
baseConversationId,
conversationType
);

if (graphContext && graphContext.type !== "dm") {
try {
Expand Down Expand Up @@ -368,15 +391,19 @@ export class TeamsGraphReader {
channelId: string,
options: ListThreadsOptions = {}
): Promise<ListThreadsResult<unknown>> {
const { conversationId, serviceUrl } = decodeThreadId(channelId);
const { conversationId, conversationType, serviceUrl } =
decodeThreadId(channelId);
const baseConversationId = conversationId.replace(
MESSAGEID_STRIP_PATTERN,
""
);
const limit = options.limit || 50;

try {
const graphContext = await this.deps.getGraphContext(baseConversationId);
const graphContext = await this.getGraphContext(
baseConversationId,
conversationType
);

this.deps.logger.debug("Teams Graph API: listThreads", {
conversationId: baseConversationId,
Expand All @@ -403,6 +430,7 @@ export class TeamsGraphReader {
}
const threadId = encodeThreadId({
conversationId: `${baseConversationId};messageid=${msg.id}`,
conversationType: "channel",
serviceUrl,
});

Expand Down Expand Up @@ -455,13 +483,17 @@ export class TeamsGraphReader {
$orderby: ["createdDateTime desc"],
});
const messages = response.value || [];
const threadConversationType =
conversationType ??
(graphContext?.type === "dm" ? "personal" : undefined);

for (const msg of messages) {
if (!msg.id) {
continue;
}
const threadId = encodeThreadId({
conversationId: `${baseConversationId};messageid=${msg.id}`,
conversationType: threadConversationType,
serviceUrl,
});

Expand Down
30 changes: 29 additions & 1 deletion packages/adapter-teams/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,15 +90,43 @@ threadIdContract<TeamsThreadId>({
fn: (id) => contractAdapter.isDM(id),
dmThreadId: contractAdapter.encodeThreadId({
conversationId: "a]8:orgid:user-id-here",
conversationType: "personal",
serviceUrl: "https://smba.trafficmanager.net/teams/",
}),
nonDmThreadId: contractAdapter.encodeThreadId({
conversationId: "19:abc@thread.tacv2",
conversationId: "a:group-chat-id",
conversationType: "groupChat",
serviceUrl: "https://smba.trafficmanager.net/teams/",
}),
},
});

describe("Teams conversation type routing", () => {
it("keeps the legacy ID when the conversation type agrees with its prefix", () => {
const personal = contractAdapter.encodeThreadId({
conversationId: "a:personal-conversation",
conversationType: "personal",
serviceUrl: "https://smba.trafficmanager.net/teams/",
});
const legacyPersonal = contractAdapter.encodeThreadId({
conversationId: "a:personal-conversation",
serviceUrl: "https://smba.trafficmanager.net/teams/",
});
const channel = contractAdapter.encodeThreadId({
conversationId: "19:channel@thread.tacv2",
conversationType: "channel",
serviceUrl: "https://smba.trafficmanager.net/teams/",
});
const legacyChannel = contractAdapter.encodeThreadId({
conversationId: "19:channel@thread.tacv2",
serviceUrl: "https://smba.trafficmanager.net/teams/",
});

expect(personal).toBe(legacyPersonal);
expect(channel).toBe(legacyChannel);
});
});

describe("ESM compatibility", () => {
it(
"all subpath imports resolve in Node.js ESM (no bare directory imports)",
Expand Down
Loading