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
11 changes: 11 additions & 0 deletions .changeset/get-user.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"chat": minor
"@chat-adapter/slack": minor
"@chat-adapter/discord": minor
"@chat-adapter/gchat": minor
"@chat-adapter/github": minor
"@chat-adapter/linear": minor
"@chat-adapter/telegram": minor
---

Add `chat.getUser()` method and `UserInfo` type for cross-platform user lookups. Implement `getUser` on Slack, Discord, Google Chat, GitHub, Linear, and Telegram adapters.
5 changes: 5 additions & 0 deletions .changeset/teams-get-user.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@chat-adapter/teams": minor
---

Add `getUser()` support for Teams adapter using Microsoft Graph API (requires `User.Read.All` permission)
74 changes: 74 additions & 0 deletions apps/docs/content/docs/api/chat.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -444,6 +444,80 @@ await dm.post("Hello via DM!");
const dm = await bot.openDM(message.author);
```

### getUser

Look up user information by user ID. Returns a `UserInfo` object with name, email, avatar, and bot status, or `null` if the user was not found. Supported on Slack, Microsoft Teams, Discord, Google Chat, GitHub, Linear, and Telegram. Other adapters will throw `NOT_SUPPORTED`.

```typescript
const user = await bot.getUser("U123456");
console.log(user?.email); // "alice@company.com"
console.log(user?.fullName); // "Alice Smith"
```

```typescript
// Or with an Author object from a message handler
const user = await bot.getUser(message.author);
```

<TypeTable
type={{
userId: {
description: 'Platform-specific user ID.',
type: 'string',
},
userName: {
description: 'Username/handle.',
type: 'string',
},
fullName: {
description: 'Display name / full name.',
type: 'string',
},
isBot: {
description: 'Whether the user is a bot.',
type: 'boolean',
},
email: {
description: 'Email address (requires scopes on some platforms).',
type: 'string | undefined',
},
avatarUrl: {
description: 'Profile image URL.',
type: 'string | undefined',
},
}}
/>

<Callout type="info">
**Per-platform constraints:**
- **Slack** — requires both `users:read` and `users:read.email` scopes (the email scope must be granted at OAuth install time).
- **Discord** — bot tokens never see email (the `email` OAuth scope only applies in user-context auth).
- **Telegram** — bots can only look up users who have previously messaged them.
- **Microsoft Teams** — only works for users who previously interacted with the bot (cached from webhook activity). `avatarUrl` is not returned (Graph API requires a separate photo call).
- **Google Chat** — same caching constraint as Teams: only users seen in prior webhooks.
- **GitHub** — `email` is `null` unless the user made it public, or you authenticated with the `user:email` scope.
- **Linear** — full profile (incl. email + avatar) for any active workspace member.

Fields that aren't available return `undefined`. Numeric user IDs (Discord/Telegram/GitHub) can be ambiguous when multiple of those adapters are registered — call the platform's adapter directly (`adapter.getUser(userId)`) in that case.
</Callout>

Adapters that don't support user lookups will throw a `ChatError` with code `NOT_SUPPORTED`. Handle both cases if your bot runs on multiple platforms:

```typescript
import { ChatError } from "chat";

try {
const user = await bot.getUser(userId);
if (!user) {
// User not found on this platform
}
} catch (error) {
if (error instanceof ChatError && error.code === "NOT_SUPPORTED") {
// This adapter doesn't support user lookups
}
}
```

### thread

Get a Thread handle by its thread ID. Useful for posting to threads outside of webhook contexts (e.g. cron jobs, external triggers).
Expand Down
7 changes: 7 additions & 0 deletions apps/docs/content/docs/threads-messages-channels.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,13 @@ interface Author {
}
```

For richer user info (email, avatar), use [`chat.getUser()`](/docs/api/chat#getuser):

```typescript title="lib/bot.ts"
const user = await bot.getUser(message.author);
console.log(user?.email); // "alice@company.com"
```

### Sent messages

When you post a message, you get back a `SentMessage` with methods to edit, delete, and react:
Expand Down
31 changes: 31 additions & 0 deletions examples/nextjs-chat/src/lib/bot.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ bot.onNewMention(async (thread, message) => {
<Button id="messages">Fetch Messages</Button>
<Button id="channel-post">Channel Post</Button>
<Button id="show-table">Show Table</Button>
<Button id="who-am-i">Who Am I</Button>
<Button actionType="modal" id="report" value="bug">
Report Bug
</Button>
Expand Down Expand Up @@ -378,6 +379,36 @@ bot.onAction("info", async (event) => {
);
});

bot.onAction("who-am-i", async (event) => {
if (!event.thread) {
return;
}
try {
const user = await bot.getUser(event.user);
if (!user) {
await event.thread.post(
`${emoji.warning} Could not find your user profile.`
);
return;
}
await event.thread.post(
<Card title={`${emoji.eyes} Who Am I`}>
<Fields>
<Field label="Name" value={user.fullName} />
<Field label="Username" value={user.userName} />
<Field label="User ID" value={user.userId} />
<Field label="Email" value={user.email ?? "Not available"} />
<Field label="Bot" value={user.isBot ? "Yes" : "No"} />
</Fields>
</Card>
);
} catch {
await event.thread.post(
`${emoji.warning} User lookup is not supported on this platform.`
);
}
});

bot.onAction("goodbye", async (event) => {
if (!event.thread) {
return;
Expand Down
165 changes: 165 additions & 0 deletions packages/adapter-discord/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4210,3 +4210,168 @@ describe("createDiscordThread 160004 recovery", () => {
spy.mockRestore();
});
});

describe("getUser", () => {
it("should return user info from Discord API", async () => {
const adapter = createDiscordAdapter({
botToken: "test-token",
publicKey: testPublicKey,
applicationId: "test-app-id",
logger: mockLogger,
});

const spy = vi.spyOn(adapter as any, "discordFetch").mockResolvedValue(
new Response(
JSON.stringify({
id: "123456",
username: "alice",
global_name: "Alice Smith",
avatar: "abc123",
bot: false,
}),
{ status: 200, headers: { "Content-Type": "application/json" } }
)
);

const user = await adapter.getUser("123456");
expect(user).not.toBeNull();
expect(user?.fullName).toBe("Alice Smith");
expect(user?.userName).toBe("alice");
expect(user?.avatarUrl).toBe(
"https://cdn.discordapp.com/avatars/123456/abc123.png"
);
expect(user?.isBot).toBe(false);
expect(user?.email).toBeUndefined();

spy.mockRestore();
});

it("should return null on error", async () => {
const adapter = createDiscordAdapter({
botToken: "test-token",
publicKey: testPublicKey,
applicationId: "test-app-id",
logger: mockLogger,
});

const spy = vi
.spyOn(adapter as any, "discordFetch")
.mockRejectedValue(new Error("Not found"));

const user = await adapter.getUser("999999");
expect(user).toBeNull();

spy.mockRestore();
});

it("should return undefined avatarUrl when avatar is null", async () => {
const adapter = createDiscordAdapter({
botToken: "test-token",
publicKey: testPublicKey,
applicationId: "test-app-id",
logger: mockLogger,
});

const spy = vi.spyOn(adapter as any, "discordFetch").mockResolvedValue(
new Response(
JSON.stringify({
id: "111222",
username: "noavatar",
global_name: "No Avatar User",
avatar: null,
bot: false,
}),
{ status: 200, headers: { "Content-Type": "application/json" } }
)
);

const user = await adapter.getUser("111222");
expect(user).not.toBeNull();
expect(user?.avatarUrl).toBeUndefined();

spy.mockRestore();
});

it("should fall back to username when global_name is null", async () => {
const adapter = createDiscordAdapter({
botToken: "test-token",
publicKey: testPublicKey,
applicationId: "test-app-id",
logger: mockLogger,
});

const spy = vi.spyOn(adapter as any, "discordFetch").mockResolvedValue(
new Response(
JSON.stringify({
id: "333444",
username: "fallbackuser",
global_name: null,
avatar: "def456",
bot: false,
}),
{ status: 200, headers: { "Content-Type": "application/json" } }
)
);

const user = await adapter.getUser("333444");
expect(user).not.toBeNull();
expect(user?.fullName).toBe("fallbackuser");

spy.mockRestore();
});

it("should return isBot true for bot users", async () => {
const adapter = createDiscordAdapter({
botToken: "test-token",
publicKey: testPublicKey,
applicationId: "test-app-id",
logger: mockLogger,
});

const spy = vi.spyOn(adapter as any, "discordFetch").mockResolvedValue(
new Response(
JSON.stringify({
id: "555666",
username: "botuser",
global_name: "Bot User",
avatar: "ghi789",
bot: true,
}),
{ status: 200, headers: { "Content-Type": "application/json" } }
)
);

const user = await adapter.getUser("555666");
expect(user).not.toBeNull();
expect(user?.isBot).toBe(true);

spy.mockRestore();
});

it("should call Discord API with correct endpoint and method", async () => {
const adapter = createDiscordAdapter({
botToken: "test-token",
publicKey: testPublicKey,
applicationId: "test-app-id",
logger: mockLogger,
});

const spy = vi.spyOn(adapter as any, "discordFetch").mockResolvedValue(
new Response(
JSON.stringify({
id: "777888",
username: "verifyuser",
global_name: "Verify User",
avatar: null,
bot: false,
}),
{ status: 200, headers: { "Content-Type": "application/json" } }
)
);

await adapter.getUser("777888");
expect(spy).toHaveBeenCalledWith("/users/777888", "GET");

spy.mockRestore();
});
});
21 changes: 21 additions & 0 deletions packages/adapter-discord/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import type {
RawMessage,
ThreadInfo,
ThreadSummary,
UserInfo,
WebhookOptions,
} from "chat";
import {
Expand Down Expand Up @@ -72,6 +73,7 @@ import {
type DiscordRequestContext,
type DiscordSlashCommandContext,
type DiscordThreadId,
type DiscordUser,
InteractionResponseType,
} from "./types";

Expand Down Expand Up @@ -153,6 +155,25 @@ export class DiscordAdapter implements Adapter<DiscordThreadId, unknown> {
this.logger.info("Discord adapter initialized");
}

async getUser(userId: string): Promise<UserInfo | null> {
try {
const response = await this.discordFetch(`/users/${userId}`, "GET");
const user = (await response.json()) as DiscordUser;
return {
avatarUrl: user.avatar
? `https://cdn.discordapp.com/avatars/${user.id}/${user.avatar}.png`
: undefined,
email: undefined,
fullName: user.global_name || user.username,
isBot: user.bot ?? false,
userId: user.id,
userName: user.username,
};
} catch {
return null;
}
}

/**
* Handle incoming Discord webhook (HTTP Interactions or forwarded Gateway events).
*/
Expand Down
Loading
Loading