Skip to content
Open
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.
65 changes: 65 additions & 0 deletions apps/docs/content/docs/api/chat.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -443,6 +443,71 @@ 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, Discord, Google Chat, GitHub, Linear, and Telegram. Other adapters will throw `NOT_SUPPORTED`.

```typescript
const user = await bot.getUser("U123456");
console.log(user?.email); // "[email protected]"
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">
Email availability varies by platform. On Slack it requires the `users:read.email` scope. Discord and Telegram don't expose email to bots. Fields that aren't available return `undefined`.
</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); // "[email protected]"
```

### 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 @@ -4062,3 +4062,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