From 516cb751cf2dda7e8defa48529429c7e5fac2918 Mon Sep 17 00:00:00 2001 From: onmax Date: Mon, 27 Jul 2026 15:08:26 +0700 Subject: [PATCH 1/2] fix(teams): classify group chats by conversation type Signed-off-by: onmax --- .changeset/calm-teams-conversations.md | 6 +++ apps/docs/content/adapters/official/teams.mdx | 4 ++ packages/adapter-teams/AGENTS.md | 7 ++-- packages/adapter-teams/README.md | 4 ++ packages/adapter-teams/src/graph-api.test.ts | 39 ++++++++++++++++++- packages/adapter-teams/src/graph-api.ts | 7 +++- packages/adapter-teams/src/index.test.ts | 20 +++++++++- packages/adapter-teams/src/index.ts | 30 +++++++++++++- packages/adapter-teams/src/thread-id.ts | 35 +++++++++++++++-- packages/adapter-teams/src/types.ts | 1 + .../fixtures/replay/teams.json | 34 ++++++++++++++++ packages/integration-tests/src/replay.test.ts | 10 +++++ packages/integration-tests/src/teams-utils.ts | 9 +++-- 13 files changed, 191 insertions(+), 15 deletions(-) create mode 100644 .changeset/calm-teams-conversations.md diff --git a/.changeset/calm-teams-conversations.md b/.changeset/calm-teams-conversations.md new file mode 100644 index 000000000..a0f57140f --- /dev/null +++ b/.changeset/calm-teams-conversations.md @@ -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. diff --git a/apps/docs/content/adapters/official/teams.mdx b/apps/docs/content/adapters/official/teams.mdx index d68970d4d..73c47f1fd 100644 --- a/apps/docs/content/adapters/official/teams.mdx +++ b/apps/docs/content/adapters/official/teams.mdx @@ -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 as `personal`, `groupChat`, or `channel`. The adapter uses that metadata to keep native streaming in personal chats and select the buffered fallback for group chats and channels, including group-chat IDs that 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: diff --git a/packages/adapter-teams/AGENTS.md b/packages/adapter-teams/AGENTS.md index 8a84ab442..4fd418bf2 100644 --- a/packages/adapter-teams/AGENTS.md +++ b/packages/adapter-teams/AGENTS.md @@ -130,13 +130,14 @@ 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} ``` `encodeThreadId` / `decodeThreadId` are the only sanctioned way to construct these — never `string.replace(":", …)` your way around the -encoding. +encoding. The optional conversation type is `personal`, `groupChat`, +or `channel`; IDs created before that segment was added remain valid. `isDM(threadId)` returns `true` when the underlying conversation type is `personal` (1:1 with the bot) — useful inside handlers that need to diff --git a/packages/adapter-teams/README.md b/packages/adapter-teams/README.md index f65c593b8..c0b60b10a 100644 --- a/packages/adapter-teams/README.md +++ b/packages/adapter-teams/README.md @@ -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 as `personal`, `groupChat`, or `channel`. The adapter uses that metadata to keep native streaming in personal chats and select the buffered fallback for group chats and channels, including group-chat IDs that 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: diff --git a/packages/adapter-teams/src/graph-api.test.ts b/packages/adapter-teams/src/graph-api.test.ts index a18646b8d..1f43824ee 100644 --- a/packages/adapter-teams/src/graph-api.test.ts +++ b/packages/adapter-teams/src/graph-api.test.ts @@ -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({ @@ -183,3 +184,39 @@ 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 () => null, + 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(threadId).toBeDefined(); + expect(decodeThreadId(threadId as string).conversationType).toBe( + "groupChat" + ); + expect(isDM(threadId as string)).toBe(false); + }); +}); diff --git a/packages/adapter-teams/src/graph-api.ts b/packages/adapter-teams/src/graph-api.ts index 72595bb6b..c9687360d 100644 --- a/packages/adapter-teams/src/graph-api.ts +++ b/packages/adapter-teams/src/graph-api.ts @@ -368,7 +368,8 @@ export class TeamsGraphReader { channelId: string, options: ListThreadsOptions = {} ): Promise> { - const { conversationId, serviceUrl } = decodeThreadId(channelId); + const { conversationId, conversationType, serviceUrl } = + decodeThreadId(channelId); const baseConversationId = conversationId.replace( MESSAGEID_STRIP_PATTERN, "" @@ -403,6 +404,7 @@ export class TeamsGraphReader { } const threadId = encodeThreadId({ conversationId: `${baseConversationId};messageid=${msg.id}`, + conversationType: "channel", serviceUrl, }); @@ -455,6 +457,8 @@ export class TeamsGraphReader { $orderby: ["createdDateTime desc"], }); const messages = response.value || []; + const threadConversationType = + graphContext?.type === "dm" ? "personal" : conversationType; for (const msg of messages) { if (!msg.id) { @@ -462,6 +466,7 @@ export class TeamsGraphReader { } const threadId = encodeThreadId({ conversationId: `${baseConversationId};messageid=${msg.id}`, + conversationType: threadConversationType, serviceUrl, }); diff --git a/packages/adapter-teams/src/index.test.ts b/packages/adapter-teams/src/index.test.ts index 3d77b920b..9003827c1 100644 --- a/packages/adapter-teams/src/index.test.ts +++ b/packages/adapter-teams/src/index.test.ts @@ -90,15 +90,33 @@ threadIdContract({ 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.each([ + ["personal", true], + ["groupChat", false], + ["channel", false], + ] as const)("classifies %s conversations", (conversationType, expected) => { + const threadId = contractAdapter.encodeThreadId({ + conversationId: "a:conversation-id", + conversationType, + serviceUrl: "https://smba.trafficmanager.net/teams/", + }); + + expect(contractAdapter.isDM(threadId)).toBe(expected); + }); +}); + describe("ESM compatibility", () => { it( "all subpath imports resolve in Node.js ESM (no bare directory imports)", diff --git a/packages/adapter-teams/src/index.ts b/packages/adapter-teams/src/index.ts index a5ba27d96..b606c95f0 100644 --- a/packages/adapter-teams/src/index.ts +++ b/packages/adapter-teams/src/index.ts @@ -64,7 +64,12 @@ import { modalToAdaptiveCard, parseDialogSubmitValues, } from "./modals"; -import { decodeThreadId, encodeThreadId, isDM } from "./thread-id"; +import { + decodeThreadId, + encodeThreadId, + isDM, + parseConversationType, +} from "./thread-id"; import type { TeamsAdapterConfig, TeamsChannelContext, @@ -354,6 +359,9 @@ export class TeamsAdapter implements Adapter { const threadId = this.encodeThreadId({ conversationId: activity.conversation?.id || "", + conversationType: parseConversationType( + activity.conversation?.conversationType + ), serviceUrl: activity.serviceUrl || "", replyToId: activity.replyToId, }); @@ -432,6 +440,9 @@ export class TeamsAdapter implements Adapter { const threadId = this.encodeThreadId({ conversationId: activity.conversation?.id || "", + conversationType: parseConversationType( + activity.conversation?.conversationType + ), serviceUrl: activity.serviceUrl || "", }); @@ -498,6 +509,9 @@ export class TeamsAdapter implements Adapter { const threadId = this.encodeThreadId({ conversationId: activity.conversation?.id || "", + conversationType: parseConversationType( + activity.conversation?.conversationType + ), serviceUrl: activity.serviceUrl || "", }); @@ -605,6 +619,9 @@ export class TeamsAdapter implements Adapter { const threadId = this.encodeThreadId({ conversationId: activity.conversation?.id || "", + conversationType: parseConversationType( + activity.conversation?.conversationType + ), serviceUrl: activity.serviceUrl || "", }); @@ -755,6 +772,9 @@ export class TeamsAdapter implements Adapter { const threadId = this.encodeThreadId({ conversationId, + conversationType: parseConversationType( + activity.conversation?.conversationType + ), serviceUrl: activity.serviceUrl || "", }); @@ -1574,6 +1594,7 @@ export class TeamsAdapter implements Adapter { return this.encodeThreadId({ conversationId, + conversationType: "personal", serviceUrl, }); } catch (error) { @@ -1597,13 +1618,15 @@ export class TeamsAdapter implements Adapter { } channelIdFromThreadId(threadId: string): string { - const { conversationId, serviceUrl } = this.decodeThreadId(threadId); + const { conversationId, conversationType, serviceUrl } = + this.decodeThreadId(threadId); const baseConversationId = conversationId.replace( MESSAGEID_STRIP_PATTERN, "" ); return this.encodeThreadId({ conversationId: baseConversationId, + conversationType, serviceUrl, }); } @@ -1706,6 +1729,9 @@ export class TeamsAdapter implements Adapter { const activity = raw as Activity; const threadId = this.encodeThreadId({ conversationId: activity.conversation?.id || "", + conversationType: parseConversationType( + activity.conversation?.conversationType + ), serviceUrl: activity.serviceUrl || "", }); return this.parseTeamsMessage(activity, threadId); diff --git a/packages/adapter-teams/src/thread-id.ts b/packages/adapter-teams/src/thread-id.ts index a705bf2c5..1a0e09099 100644 --- a/packages/adapter-teams/src/thread-id.ts +++ b/packages/adapter-teams/src/thread-id.ts @@ -1,6 +1,15 @@ import { ValidationError } from "@chat-adapter/shared"; import type { TeamsThreadId } from "./types"; +export function parseConversationType( + value: unknown +): TeamsThreadId["conversationType"] { + if (value === "channel" || value === "groupChat" || value === "personal") { + return value; + } + return undefined; +} + export function encodeThreadId(platformData: TeamsThreadId): string { const encodedConversationId = Buffer.from( platformData.conversationId @@ -8,12 +17,16 @@ export function encodeThreadId(platformData: TeamsThreadId): string { const encodedServiceUrl = Buffer.from(platformData.serviceUrl).toString( "base64url" ); - return `teams:${encodedConversationId}:${encodedServiceUrl}`; + const conversationType = platformData.conversationType; + return conversationType + ? `teams:${encodedConversationId}:${encodedServiceUrl}:${conversationType}` + : `teams:${encodedConversationId}:${encodedServiceUrl}`; } export function decodeThreadId(threadId: string): TeamsThreadId { const parts = threadId.split(":"); - if (parts.length !== 3 || parts[0] !== "teams") { + const hasValidPartCount = parts.length === 3 || parts.length === 4; + if (!hasValidPartCount || parts[0] !== "teams") { throw new ValidationError("teams", `Invalid Teams thread ID: ${threadId}`); } const conversationId = Buffer.from(parts[1] as string, "base64url").toString( @@ -22,10 +35,24 @@ export function decodeThreadId(threadId: string): TeamsThreadId { const serviceUrl = Buffer.from(parts[2] as string, "base64url").toString( "utf-8" ); - return { conversationId, serviceUrl }; + const rawConversationType = parts[3]; + if (!rawConversationType) { + return { conversationId, serviceUrl }; + } + const conversationType = parseConversationType(rawConversationType); + if (!conversationType) { + throw new ValidationError( + "teams", + `Invalid Teams conversation type: ${rawConversationType}` + ); + } + return { conversationId, conversationType, serviceUrl }; } export function isDM(threadId: string): boolean { - const { conversationId } = decodeThreadId(threadId); + const { conversationId, conversationType } = decodeThreadId(threadId); + if (conversationType) { + return conversationType === "personal"; + } return !conversationId.startsWith("19:"); } diff --git a/packages/adapter-teams/src/types.ts b/packages/adapter-teams/src/types.ts index df794b967..f72123d18 100644 --- a/packages/adapter-teams/src/types.ts +++ b/packages/adapter-teams/src/types.ts @@ -57,6 +57,7 @@ export interface TeamsAdapterConfig { /** Teams-specific thread ID data */ export interface TeamsThreadId { conversationId: string; + conversationType?: "channel" | "groupChat" | "personal"; replyToId?: string; serviceUrl: string; } diff --git a/packages/integration-tests/fixtures/replay/teams.json b/packages/integration-tests/fixtures/replay/teams.json index 254187ec3..903fe367d 100644 --- a/packages/integration-tests/fixtures/replay/teams.json +++ b/packages/integration-tests/fixtures/replay/teams.json @@ -36,6 +36,40 @@ "tenant": { "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890" } } }, + "groupMention": { + "text": "Chat SDK Demo Hello", + "textFormat": "plain", + "type": "message", + "id": "1785137220000", + "channelId": "msteams", + "serviceUrl": "https://smba.trafficmanager.net/amer/a1b2c3d4-e5f6-7890-abcd-ef1234567890/", + "from": { + "id": "29:1xXxFakeUserBase64IdStringForTeamsPlatformAbcDeFgHiJkLmNoPqRsTuVwXyZ012345ABCDEF", + "name": "Test User" + }, + "conversation": { + "isGroup": true, + "conversationType": "groupChat", + "tenantId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "id": "a:group-chat-id" + }, + "recipient": { + "id": "28:11111111-2222-3333-4444-555555555555", + "name": "Chat SDK Demo" + }, + "entities": [ + { + "mentioned": { + "id": "28:11111111-2222-3333-4444-555555555555", + "name": "Chat SDK Demo" + }, + "type": "mention" + } + ], + "channelData": { + "tenant": { "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890" } + } + }, "followUp": { "text": "Hi", "textFormat": "plain", diff --git a/packages/integration-tests/src/replay.test.ts b/packages/integration-tests/src/replay.test.ts index 47adbbc02..290acaef6 100644 --- a/packages/integration-tests/src/replay.test.ts +++ b/packages/integration-tests/src/replay.test.ts @@ -263,6 +263,16 @@ describe("Replay Tests", () => { expectSentMessage(ctx.mockTeamsApp, "Thanks for mentioning me!"); }); + it("should route an a:-prefixed group chat as non-DM", async () => { + await ctx.sendWebhook(teamsFixtures.groupMention); + + expectValidMention(ctx.captured, { + textContains: "Hello", + adapterName: "teams", + }); + expect(ctx.captured.mentionThread?.isDM).toBe(false); + }); + it("should replay follow-up with correct message properties", async () => { // First send mention to subscribe await ctx.sendWebhook(teamsFixtures.mention); diff --git a/packages/integration-tests/src/teams-utils.ts b/packages/integration-tests/src/teams-utils.ts index 7d168e355..97626f27b 100644 --- a/packages/integration-tests/src/teams-utils.ts +++ b/packages/integration-tests/src/teams-utils.ts @@ -17,6 +17,7 @@ export const TEAMS_BOT_NAME = "TestBot"; */ export interface TeamsActivityOptions { conversationId: string; + conversationType?: "channel" | "groupChat" | "personal"; fromId: string; fromName: string; isFromBot?: boolean; @@ -40,6 +41,7 @@ export function createTeamsActivity(options: TeamsActivityOptions) { text, messageId, conversationId, + conversationType = "groupChat", serviceUrl = "https://smba.trafficmanager.net/teams/", fromId, fromName, @@ -76,7 +78,7 @@ export function createTeamsActivity(options: TeamsActivityOptions) { }, conversation: { id: conversationId, - conversationType: "personal", + conversationType, tenantId: "tenant-123", }, recipient: { @@ -308,12 +310,13 @@ export function injectMockTeamsApp( */ export function getTeamsThreadId( conversationId: string, - serviceUrl: string + serviceUrl: string, + conversationType: "channel" | "groupChat" | "personal" = "groupChat" ): string { const encodedConversationId = Buffer.from(conversationId).toString("base64url"); const encodedServiceUrl = Buffer.from(serviceUrl).toString("base64url"); - return `teams:${encodedConversationId}:${encodedServiceUrl}`; + return `teams:${encodedConversationId}:${encodedServiceUrl}:${conversationType}`; } /** From fcfbcf5c92f6c538f955133a1c07161f0e6804fc Mon Sep 17 00:00:00 2001 From: onmax Date: Mon, 27 Jul 2026 17:59:40 +0700 Subject: [PATCH 2/2] fix(teams): preserve compatible thread identities Signed-off-by: onmax --- apps/docs/content/adapters/official/teams.mdx | 2 +- apps/docs/content/docs/api/channel.mdx | 2 +- .../docs/threads-messages-channels.mdx | 2 +- packages/adapter-teams/AGENTS.md | 8 ++- packages/adapter-teams/README.md | 2 +- packages/adapter-teams/src/graph-api.test.ts | 9 ++- packages/adapter-teams/src/graph-api.ts | 45 ++++++++++--- packages/adapter-teams/src/index.test.ts | 28 +++++--- packages/adapter-teams/src/index.ts | 67 +++++++------------ packages/adapter-teams/src/thread-id.ts | 7 +- .../fixtures/replay/teams.json | 34 ---------- packages/integration-tests/src/replay.test.ts | 10 --- packages/integration-tests/src/teams-utils.ts | 7 +- packages/integration-tests/src/teams.test.ts | 2 +- 14 files changed, 104 insertions(+), 121 deletions(-) diff --git a/apps/docs/content/adapters/official/teams.mdx b/apps/docs/content/adapters/official/teams.mdx index 73c47f1fd..45795eafb 100644 --- a/apps/docs/content/adapters/official/teams.mdx +++ b/apps/docs/content/adapters/official/teams.mdx @@ -221,7 +221,7 @@ The Teams SDK reads a generic `CLIENT_SECRET` environment variable and prefers i ### Conversation routing -Incoming thread IDs preserve the Teams conversation type as `personal`, `groupChat`, or `channel`. The adapter uses that metadata to keep native streaming in personal chats and select the buffered fallback for group chats and channels, including group-chat IDs that begin with `a:`. Thread IDs created by older adapter versions remain supported. +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 diff --git a/apps/docs/content/docs/api/channel.mdx b/apps/docs/content/docs/api/channel.mdx index 9b042e0b6..d1f69342c 100644 --- a/apps/docs/content/docs/api/channel.mdx +++ b/apps/docs/content/docs/api/channel.mdx @@ -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}` | diff --git a/apps/docs/content/docs/threads-messages-channels.mdx b/apps/docs/content/docs/threads-messages-channels.mdx index f8fbd5052..3ed1f63e1 100644 --- a/apps/docs/content/docs/threads-messages-channels.mdx +++ b/apps/docs/content/docs/threads-messages-channels.mdx @@ -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}` diff --git a/packages/adapter-teams/AGENTS.md b/packages/adapter-teams/AGENTS.md index 4fd418bf2..d17f4fe53 100644 --- a/packages/adapter-teams/AGENTS.md +++ b/packages/adapter-teams/AGENTS.md @@ -131,13 +131,15 @@ them before assembling the Chat SDK identifier: ``` teams:{conversationId_base64url}:{serviceUrl_base64url} # legacy -teams:{conversationId_base64url}:{serviceUrl_base64url}:{conversationType} +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. The optional conversation type is `personal`, `groupChat`, -or `channel`; IDs created before that segment was added remain valid. +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 diff --git a/packages/adapter-teams/README.md b/packages/adapter-teams/README.md index c0b60b10a..a60f45728 100644 --- a/packages/adapter-teams/README.md +++ b/packages/adapter-teams/README.md @@ -207,7 +207,7 @@ TEAMS_API_URL=... # Optional, for GCC-High or sovereign-cloud deployments ## Conversation routing -Incoming thread IDs preserve the Teams conversation type as `personal`, `groupChat`, or `channel`. The adapter uses that metadata to keep native streaming in personal chats and select the buffered fallback for group chats and channels, including group-chat IDs that begin with `a:`. Thread IDs created by older adapter versions remain supported. +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`) diff --git a/packages/adapter-teams/src/graph-api.test.ts b/packages/adapter-teams/src/graph-api.test.ts index 1f43824ee..b7449658b 100644 --- a/packages/adapter-teams/src/graph-api.test.ts +++ b/packages/adapter-teams/src/graph-api.test.ts @@ -201,7 +201,10 @@ describe("listThreads", () => { botId: "test-app", graph: graph as unknown as GraphClient, formatConverter: new TeamsFormatConverter(), - getGraphContext: async () => null, + getGraphContext: async () => ({ + type: "dm", + graphChatId: "19:stale-personal-chat@unq.gbl.spaces", + }), logger: new ConsoleLogger("error"), }); const channelId = encodeThreadId({ @@ -213,6 +216,10 @@ describe("listThreads", () => { 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" diff --git a/packages/adapter-teams/src/graph-api.ts b/packages/adapter-teams/src/graph-api.ts index c9687360d..60715d915 100644 --- a/packages/adapter-teams/src/graph-api.ts +++ b/packages/adapter-teams/src/graph-api.ts @@ -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+)/; @@ -62,11 +66,21 @@ export class TeamsGraphReader { return baseConversationId; } + private async getGraphContext( + baseConversationId: string, + conversationType: TeamsThreadId["conversationType"] + ): Promise { + if (conversationType === "groupChat") { + return null; + } + return this.deps.getGraphContext(baseConversationId); + } + async fetchMessages( threadId: string, options: FetchOptions = {} ): Promise> { - const { conversationId } = decodeThreadId(threadId); + const { conversationId, conversationType } = decodeThreadId(threadId); const limit = options.limit || 50; const cursor = options.cursor; const direction = options.direction ?? "backward"; @@ -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", { @@ -180,7 +197,7 @@ export class TeamsGraphReader { channelId: string, options: FetchOptions = {} ): Promise> { - const { conversationId } = decodeThreadId(channelId); + const { conversationId, conversationType } = decodeThreadId(channelId); const baseConversationId = conversationId.replace( MESSAGEID_STRIP_PATTERN, "" @@ -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, @@ -307,13 +327,16 @@ export class TeamsGraphReader { } async fetchChannelInfo(channelId: string): Promise { - 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 { @@ -377,7 +400,10 @@ export class TeamsGraphReader { 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, @@ -458,7 +484,8 @@ export class TeamsGraphReader { }); const messages = response.value || []; const threadConversationType = - graphContext?.type === "dm" ? "personal" : conversationType; + conversationType ?? + (graphContext?.type === "dm" ? "personal" : undefined); for (const msg of messages) { if (!msg.id) { diff --git a/packages/adapter-teams/src/index.test.ts b/packages/adapter-teams/src/index.test.ts index 9003827c1..368e54e21 100644 --- a/packages/adapter-teams/src/index.test.ts +++ b/packages/adapter-teams/src/index.test.ts @@ -102,18 +102,28 @@ threadIdContract({ }); describe("Teams conversation type routing", () => { - it.each([ - ["personal", true], - ["groupChat", false], - ["channel", false], - ] as const)("classifies %s conversations", (conversationType, expected) => { - const threadId = contractAdapter.encodeThreadId({ - conversationId: "a:conversation-id", - conversationType, + 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(contractAdapter.isDM(threadId)).toBe(expected); + expect(personal).toBe(legacyPersonal); + expect(channel).toBe(legacyChannel); }); }); diff --git a/packages/adapter-teams/src/index.ts b/packages/adapter-teams/src/index.ts index b606c95f0..75ee16515 100644 --- a/packages/adapter-teams/src/index.ts +++ b/packages/adapter-teams/src/index.ts @@ -263,7 +263,13 @@ export class TeamsAdapter implements Adapter { // Cache DM context for Graph API chat ID resolution const aadObjectId = (activity.from as { aadObjectId?: string }).aadObjectId; - if (aadObjectId && this.app.id && !baseChannelId.startsWith("19:")) { + const conversationType = parseConversationType( + activity.conversation?.conversationType + ); + const isPersonalConversation = conversationType + ? conversationType === "personal" + : !baseChannelId.startsWith("19:"); + if (aadObjectId && this.app.id && isPersonalConversation) { const dmContext: TeamsDmContext = { type: "dm", graphChatId: `19:${aadObjectId}_${this.app.id}@unq.gbl.spaces`, @@ -357,14 +363,7 @@ export class TeamsAdapter implements Adapter { return; } - const threadId = this.encodeThreadId({ - conversationId: activity.conversation?.id || "", - conversationType: parseConversationType( - activity.conversation?.conversationType - ), - serviceUrl: activity.serviceUrl || "", - replyToId: activity.replyToId, - }); + const threadId = this.threadIdFromActivity(activity); const message = this.parseTeamsMessage(activity, threadId); const user = activity.from?.aadObjectId @@ -438,13 +437,7 @@ export class TeamsAdapter implements Adapter { return; } - const threadId = this.encodeThreadId({ - conversationId: activity.conversation?.id || "", - conversationType: parseConversationType( - activity.conversation?.conversationType - ), - serviceUrl: activity.serviceUrl || "", - }); + const threadId = this.threadIdFromActivity(activity); // Auto-submit fan-out: fire onAction for each input value if (actionValue.actionId === AUTO_SUBMIT_ACTION_ID) { @@ -507,13 +500,7 @@ export class TeamsAdapter implements Adapter { return; } - const threadId = this.encodeThreadId({ - conversationId: activity.conversation?.id || "", - conversationType: parseConversationType( - activity.conversation?.conversationType - ), - serviceUrl: activity.serviceUrl || "", - }); + const threadId = this.threadIdFromActivity(activity); // Auto-submit fan-out: fire onAction for each input value if (actionData.actionId === AUTO_SUBMIT_ACTION_ID) { @@ -617,13 +604,7 @@ export class TeamsAdapter implements Adapter { const activity = ctx.activity; const actionData = (activity.value?.data || {}) as ActionSubmitData; - const threadId = this.encodeThreadId({ - conversationId: activity.conversation?.id || "", - conversationType: parseConversationType( - activity.conversation?.conversationType - ), - serviceUrl: activity.serviceUrl || "", - }); + const threadId = this.threadIdFromActivity(activity); let resolveModal: (result: { modal: ModalElement; @@ -770,13 +751,7 @@ export class TeamsAdapter implements Adapter { const messageIdMatch = conversationId.match(MESSAGEID_CAPTURE_PATTERN); const messageId = messageIdMatch?.[1] || activity.replyToId || ""; - const threadId = this.encodeThreadId({ - conversationId, - conversationType: parseConversationType( - activity.conversation?.conversationType - ), - serviceUrl: activity.serviceUrl || "", - }); + const threadId = this.threadIdFromActivity(activity); const user = { userId: activity.from?.id || "unknown", @@ -1717,6 +1692,16 @@ export class TeamsAdapter implements Adapter { return encodeThreadId(platformData); } + private threadIdFromActivity(activity: Activity): string { + return this.encodeThreadId({ + conversationId: activity.conversation?.id || "", + conversationType: parseConversationType( + activity.conversation?.conversationType + ), + serviceUrl: activity.serviceUrl || "", + }); + } + isDM(threadId: string): boolean { return isDM(threadId); } @@ -1727,13 +1712,7 @@ export class TeamsAdapter implements Adapter { parseMessage(raw: unknown): Message { const activity = raw as Activity; - const threadId = this.encodeThreadId({ - conversationId: activity.conversation?.id || "", - conversationType: parseConversationType( - activity.conversation?.conversationType - ), - serviceUrl: activity.serviceUrl || "", - }); + const threadId = this.threadIdFromActivity(activity); return this.parseTeamsMessage(activity, threadId); } diff --git a/packages/adapter-teams/src/thread-id.ts b/packages/adapter-teams/src/thread-id.ts index 1a0e09099..6d91dca26 100644 --- a/packages/adapter-teams/src/thread-id.ts +++ b/packages/adapter-teams/src/thread-id.ts @@ -18,7 +18,12 @@ export function encodeThreadId(platformData: TeamsThreadId): string { "base64url" ); const conversationType = platformData.conversationType; - return conversationType + const legacyIsDM = !platformData.conversationId.startsWith("19:"); + const explicitIsDM = conversationType === "personal"; + const needsConversationType = + conversationType !== undefined && explicitIsDM !== legacyIsDM; + + return needsConversationType ? `teams:${encodedConversationId}:${encodedServiceUrl}:${conversationType}` : `teams:${encodedConversationId}:${encodedServiceUrl}`; } diff --git a/packages/integration-tests/fixtures/replay/teams.json b/packages/integration-tests/fixtures/replay/teams.json index 903fe367d..254187ec3 100644 --- a/packages/integration-tests/fixtures/replay/teams.json +++ b/packages/integration-tests/fixtures/replay/teams.json @@ -36,40 +36,6 @@ "tenant": { "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890" } } }, - "groupMention": { - "text": "Chat SDK Demo Hello", - "textFormat": "plain", - "type": "message", - "id": "1785137220000", - "channelId": "msteams", - "serviceUrl": "https://smba.trafficmanager.net/amer/a1b2c3d4-e5f6-7890-abcd-ef1234567890/", - "from": { - "id": "29:1xXxFakeUserBase64IdStringForTeamsPlatformAbcDeFgHiJkLmNoPqRsTuVwXyZ012345ABCDEF", - "name": "Test User" - }, - "conversation": { - "isGroup": true, - "conversationType": "groupChat", - "tenantId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", - "id": "a:group-chat-id" - }, - "recipient": { - "id": "28:11111111-2222-3333-4444-555555555555", - "name": "Chat SDK Demo" - }, - "entities": [ - { - "mentioned": { - "id": "28:11111111-2222-3333-4444-555555555555", - "name": "Chat SDK Demo" - }, - "type": "mention" - } - ], - "channelData": { - "tenant": { "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890" } - } - }, "followUp": { "text": "Hi", "textFormat": "plain", diff --git a/packages/integration-tests/src/replay.test.ts b/packages/integration-tests/src/replay.test.ts index 290acaef6..47adbbc02 100644 --- a/packages/integration-tests/src/replay.test.ts +++ b/packages/integration-tests/src/replay.test.ts @@ -263,16 +263,6 @@ describe("Replay Tests", () => { expectSentMessage(ctx.mockTeamsApp, "Thanks for mentioning me!"); }); - it("should route an a:-prefixed group chat as non-DM", async () => { - await ctx.sendWebhook(teamsFixtures.groupMention); - - expectValidMention(ctx.captured, { - textContains: "Hello", - adapterName: "teams", - }); - expect(ctx.captured.mentionThread?.isDM).toBe(false); - }); - it("should replay follow-up with correct message properties", async () => { // First send mention to subscribe await ctx.sendWebhook(teamsFixtures.mention); diff --git a/packages/integration-tests/src/teams-utils.ts b/packages/integration-tests/src/teams-utils.ts index 97626f27b..7db4d4296 100644 --- a/packages/integration-tests/src/teams-utils.ts +++ b/packages/integration-tests/src/teams-utils.ts @@ -3,7 +3,7 @@ * Updated for TeamsSDK (@microsoft/teams.apps) migration. */ -import type { TeamsAdapter } from "@chat-adapter/teams"; +import { encodeThreadId, type TeamsAdapter } from "@chat-adapter/teams"; import { vi } from "vitest"; export const TEAMS_APP_ID = "test-app-id"; @@ -313,10 +313,7 @@ export function getTeamsThreadId( serviceUrl: string, conversationType: "channel" | "groupChat" | "personal" = "groupChat" ): string { - const encodedConversationId = - Buffer.from(conversationId).toString("base64url"); - const encodedServiceUrl = Buffer.from(serviceUrl).toString("base64url"); - return `teams:${encodedConversationId}:${encodedServiceUrl}:${conversationType}`; + return encodeThreadId({ conversationId, conversationType, serviceUrl }); } /** diff --git a/packages/integration-tests/src/teams.test.ts b/packages/integration-tests/src/teams.test.ts index b5179f372..235689008 100644 --- a/packages/integration-tests/src/teams.test.ts +++ b/packages/integration-tests/src/teams.test.ts @@ -35,7 +35,7 @@ describe("Teams Integration", () => { let mockTeamsApp: MockTeamsApp; let tracker: ReturnType; - const TEST_CONVERSATION_ID = "19:meeting_123@thread.v2"; + const TEST_CONVERSATION_ID = "a:group-chat-id"; const TEST_THREAD_ID = getTeamsThreadId( TEST_CONVERSATION_ID, DEFAULT_TEAMS_SERVICE_URL