diff --git a/.changeset/get-user.md b/.changeset/get-user.md
new file mode 100644
index 00000000..b7f9597b
--- /dev/null
+++ b/.changeset/get-user.md
@@ -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.
diff --git a/apps/docs/content/docs/api/chat.mdx b/apps/docs/content/docs/api/chat.mdx
index 977225dd..f337c62b 100644
--- a/apps/docs/content/docs/api/chat.mdx
+++ b/apps/docs/content/docs/api/chat.mdx
@@ -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); // "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);
+```
+
+
+
+
+ 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`.
+
+
+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).
diff --git a/apps/docs/content/docs/threads-messages-channels.mdx b/apps/docs/content/docs/threads-messages-channels.mdx
index 5c68f74e..b3b1ecec 100644
--- a/apps/docs/content/docs/threads-messages-channels.mdx
+++ b/apps/docs/content/docs/threads-messages-channels.mdx
@@ -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:
diff --git a/examples/nextjs-chat/src/lib/bot.tsx b/examples/nextjs-chat/src/lib/bot.tsx
index 17bfda99..02ff10a5 100644
--- a/examples/nextjs-chat/src/lib/bot.tsx
+++ b/examples/nextjs-chat/src/lib/bot.tsx
@@ -124,6 +124,7 @@ bot.onNewMention(async (thread, message) => {
+
@@ -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(
+
+
+
+
+
+
+
+
+
+ );
+ } catch {
+ await event.thread.post(
+ `${emoji.warning} User lookup is not supported on this platform.`
+ );
+ }
+});
+
bot.onAction("goodbye", async (event) => {
if (!event.thread) {
return;
diff --git a/packages/adapter-discord/src/index.test.ts b/packages/adapter-discord/src/index.test.ts
index d4e90b80..ae3e48d7 100644
--- a/packages/adapter-discord/src/index.test.ts
+++ b/packages/adapter-discord/src/index.test.ts
@@ -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();
+ });
+});
diff --git a/packages/adapter-discord/src/index.ts b/packages/adapter-discord/src/index.ts
index 7884de9e..a39f013e 100644
--- a/packages/adapter-discord/src/index.ts
+++ b/packages/adapter-discord/src/index.ts
@@ -29,6 +29,7 @@ import type {
RawMessage,
ThreadInfo,
ThreadSummary,
+ UserInfo,
WebhookOptions,
} from "chat";
import {
@@ -72,6 +73,7 @@ import {
type DiscordRequestContext,
type DiscordSlashCommandContext,
type DiscordThreadId,
+ type DiscordUser,
InteractionResponseType,
} from "./types";
@@ -153,6 +155,25 @@ export class DiscordAdapter implements Adapter {
this.logger.info("Discord adapter initialized");
}
+ async getUser(userId: string): Promise {
+ 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).
*/
diff --git a/packages/adapter-gchat/src/index.test.ts b/packages/adapter-gchat/src/index.test.ts
index 357cedd1..2b2b7aa3 100644
--- a/packages/adapter-gchat/src/index.test.ts
+++ b/packages/adapter-gchat/src/index.test.ts
@@ -2585,6 +2585,7 @@ describe("GoogleChatAdapter", () => {
message: {
name: "spaces/ABC123/messages/msg1",
sender: {
+ avatarUrl: "https://lh3.googleusercontent.com/a/photo.jpg",
name: "users/123456789",
displayName: "John Doe",
type: "HUMAN",
@@ -2602,7 +2603,12 @@ describe("GoogleChatAdapter", () => {
// Verify user info was cached
expect(mockState.set).toHaveBeenCalledWith(
"gchat:user:users/123456789",
- { displayName: "John Doe", email: "john@example.com" },
+ {
+ avatarUrl: "https://lh3.googleusercontent.com/a/photo.jpg",
+ displayName: "John Doe",
+ email: "john@example.com",
+ isBot: false,
+ },
expect.any(Number)
);
});
@@ -3008,4 +3014,84 @@ describe("GoogleChatAdapter", () => {
expect(response.status).toBe(401);
});
});
+
+ describe("getUser", () => {
+ it("should return cached user info", async () => {
+ const { adapter, mockState } = await createInitializedAdapter();
+
+ mockState.storage.set("gchat:user:users/123456", {
+ avatarUrl: "https://lh3.googleusercontent.com/a/alice.jpg",
+ displayName: "Alice Smith",
+ email: "alice@example.com",
+ isBot: false,
+ });
+
+ const user = await adapter.getUser("users/123456");
+ expect(user).not.toBeNull();
+ expect(user?.fullName).toBe("Alice Smith");
+ expect(user?.userName).toBe("Alice Smith");
+ expect(user?.email).toBe("alice@example.com");
+ expect(user?.avatarUrl).toBe(
+ "https://lh3.googleusercontent.com/a/alice.jpg"
+ );
+ expect(user?.isBot).toBe(false);
+ });
+
+ it("should return null when user not in cache", async () => {
+ const { adapter } = await createInitializedAdapter();
+
+ const user = await adapter.getUser("users/unknown");
+ expect(user).toBeNull();
+ });
+
+ it("should return null when state throws an error", async () => {
+ const { adapter, mockState } = await createInitializedAdapter();
+
+ mockState.get = vi.fn().mockRejectedValue(new Error("State error"));
+
+ const user = await adapter.getUser("users/error");
+ expect(user).toBeNull();
+ });
+
+ it("should return undefined email when user has no email", async () => {
+ const { adapter, mockState } = await createInitializedAdapter();
+
+ mockState.storage.set("gchat:user:users/noemail", {
+ displayName: "No Email User",
+ isBot: false,
+ });
+
+ const user = await adapter.getUser("users/noemail");
+ expect(user).not.toBeNull();
+ expect(user?.fullName).toBe("No Email User");
+ expect(user?.email).toBeUndefined();
+ });
+
+ it("should return isBot true for cached bot users", async () => {
+ const { adapter, mockState } = await createInitializedAdapter();
+
+ mockState.storage.set("gchat:user:users/bot123", {
+ displayName: "Bot User",
+ isBot: true,
+ });
+
+ const user = await adapter.getUser("users/bot123");
+ expect(user).not.toBeNull();
+ expect(user?.isBot).toBe(true);
+ });
+
+ it("should return undefined avatarUrl when not cached", async () => {
+ const { adapter, mockState } = await createInitializedAdapter();
+
+ mockState.storage.set("gchat:user:users/avatar-test", {
+ displayName: "Avatar Test",
+ email: "test@example.com",
+ isBot: false,
+ });
+
+ const user = await adapter.getUser("users/avatar-test");
+ expect(user).not.toBeNull();
+ expect(user?.avatarUrl).toBeUndefined();
+ });
+ });
});
diff --git a/packages/adapter-gchat/src/index.ts b/packages/adapter-gchat/src/index.ts
index 8721b89e..1d368a15 100644
--- a/packages/adapter-gchat/src/index.ts
+++ b/packages/adapter-gchat/src/index.ts
@@ -27,6 +27,7 @@ import type {
StateAdapter,
ThreadInfo,
ThreadSummary,
+ UserInfo,
WebhookOptions,
} from "chat";
import {
@@ -187,6 +188,7 @@ export interface GoogleChatMessage {
formattedText?: string;
name: string;
sender: {
+ avatarUrl?: string;
name: string;
displayName: string;
type: string;
@@ -705,6 +707,25 @@ export class GoogleChatAdapter implements Adapter {
}
}
+ async getUser(userId: string): Promise {
+ try {
+ const cached = await this.userInfoCache.get(userId);
+ if (!cached) {
+ return null;
+ }
+ return {
+ avatarUrl: cached.avatarUrl,
+ email: cached.email,
+ fullName: cached.displayName,
+ isBot: cached.isBot ?? false,
+ userId,
+ userName: cached.displayName,
+ };
+ } catch {
+ return null;
+ }
+ }
+
async handleWebhook(
request: Request,
options?: WebhookOptions
@@ -1235,7 +1256,13 @@ export class GoogleChatAdapter implements Adapter {
const displayName = message.sender?.displayName || "unknown";
if (userId !== "unknown" && displayName !== "unknown") {
this.userInfoCache
- .set(userId, displayName, message.sender?.email)
+ .set(
+ userId,
+ displayName,
+ message.sender?.email,
+ message.sender?.type === "BOT",
+ message.sender?.avatarUrl
+ )
.catch((error) => {
this.logger.error("Failed to cache user info", { userId, error });
});
diff --git a/packages/adapter-gchat/src/user-info.ts b/packages/adapter-gchat/src/user-info.ts
index 7622f9ba..037618a2 100644
--- a/packages/adapter-gchat/src/user-info.ts
+++ b/packages/adapter-gchat/src/user-info.ts
@@ -14,8 +14,10 @@ const USER_INFO_CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000;
/** Cached user info */
export interface CachedUserInfo {
+ avatarUrl?: string;
displayName: string;
email?: string;
+ isBot?: boolean;
}
/**
@@ -38,13 +40,15 @@ export class UserInfoCache {
async set(
userId: string,
displayName: string,
- email?: string
+ email?: string,
+ isBot?: boolean,
+ avatarUrl?: string
): Promise {
if (!displayName || displayName === "unknown") {
return;
}
- const userInfo: CachedUserInfo = { displayName, email };
+ const userInfo: CachedUserInfo = { avatarUrl, displayName, email, isBot };
// Always update in-memory cache
this.inMemoryCache.set(userId, userInfo);
diff --git a/packages/adapter-github/src/index.test.ts b/packages/adapter-github/src/index.test.ts
index c856c4b3..3b8c5f9e 100644
--- a/packages/adapter-github/src/index.test.ts
+++ b/packages/adapter-github/src/index.test.ts
@@ -28,6 +28,7 @@ const mockReactionsDeleteForIssueComment = vi.fn();
const mockReactionsDeleteForPullRequestComment = vi.fn();
const mockUsersGetAuthenticated = vi.fn();
const mockReposGet = vi.fn();
+const mockRequest = vi.fn();
vi.mock("@octokit/rest", () => {
class MockOctokit {
@@ -62,6 +63,7 @@ vi.mock("@octokit/rest", () => {
repos = {
get: mockReposGet,
};
+ request = mockRequest;
}
return { Octokit: MockOctokit };
});
@@ -2376,3 +2378,134 @@ describe("createGitHubAdapter", () => {
);
});
});
+
+describe("getUser", () => {
+ it("should return user info from GitHub API", async () => {
+ mockRequest.mockResolvedValue({
+ data: {
+ id: 12345,
+ login: "alice",
+ name: "Alice Smith",
+ email: "alice@example.com",
+ avatar_url: "https://avatars.githubusercontent.com/u/12345",
+ type: "User",
+ },
+ });
+
+ const adapter = createGitHubAdapter({
+ token: "ghp_test",
+ webhookSecret: "secret",
+ });
+
+ const user = await adapter.getUser("12345");
+ expect(user).not.toBeNull();
+ expect(user?.fullName).toBe("Alice Smith");
+ expect(user?.userName).toBe("alice");
+ expect(user?.email).toBe("alice@example.com");
+ expect(user?.avatarUrl).toBe(
+ "https://avatars.githubusercontent.com/u/12345"
+ );
+ expect(user?.isBot).toBe(false);
+ });
+
+ it("should return null on error", async () => {
+ mockRequest.mockRejectedValue(new Error("Not found"));
+
+ const adapter = createGitHubAdapter({
+ token: "ghp_test",
+ webhookSecret: "secret",
+ });
+
+ const user = await adapter.getUser("999999");
+ expect(user).toBeNull();
+ });
+
+ it("should call GitHub API with correct endpoint and params", async () => {
+ mockRequest.mockResolvedValue({
+ data: {
+ id: 12345,
+ login: "alice",
+ name: "Alice Smith",
+ email: null,
+ avatar_url: "https://avatars.githubusercontent.com/u/12345",
+ type: "User",
+ },
+ });
+
+ const adapter = createGitHubAdapter({
+ token: "ghp_test",
+ webhookSecret: "secret",
+ });
+
+ await adapter.getUser("12345");
+ expect(mockRequest).toHaveBeenCalledWith("GET /user/{account_id}", {
+ account_id: Number("12345"),
+ });
+ });
+
+ it("should return isBot true for Bot type users", async () => {
+ mockRequest.mockResolvedValue({
+ data: {
+ id: 99999,
+ login: "dependabot[bot]",
+ name: "Dependabot",
+ email: null,
+ avatar_url: "https://avatars.githubusercontent.com/u/99999",
+ type: "Bot",
+ },
+ });
+
+ const adapter = createGitHubAdapter({
+ token: "ghp_test",
+ webhookSecret: "secret",
+ });
+
+ const user = await adapter.getUser("99999");
+ expect(user).not.toBeNull();
+ expect(user?.isBot).toBe(true);
+ });
+
+ it("should fall back to login when name is null", async () => {
+ mockRequest.mockResolvedValue({
+ data: {
+ id: 55555,
+ login: "noname-user",
+ name: null,
+ email: null,
+ avatar_url: "https://avatars.githubusercontent.com/u/55555",
+ type: "User",
+ },
+ });
+
+ const adapter = createGitHubAdapter({
+ token: "ghp_test",
+ webhookSecret: "secret",
+ });
+
+ const user = await adapter.getUser("55555");
+ expect(user).not.toBeNull();
+ expect(user?.fullName).toBe("noname-user");
+ });
+
+ it("should include userId in the response", async () => {
+ mockRequest.mockResolvedValue({
+ data: {
+ id: 12345,
+ login: "alice",
+ name: "Alice Smith",
+ email: "alice@example.com",
+ avatar_url: "https://avatars.githubusercontent.com/u/12345",
+ type: "User",
+ },
+ });
+
+ const adapter = createGitHubAdapter({
+ token: "ghp_test",
+ webhookSecret: "secret",
+ });
+
+ const user = await adapter.getUser("12345");
+ expect(user).not.toBeNull();
+ expect(user?.userId).toBe("12345");
+ });
+});
diff --git a/packages/adapter-github/src/index.ts b/packages/adapter-github/src/index.ts
index 1c5278da..6dd68986 100644
--- a/packages/adapter-github/src/index.ts
+++ b/packages/adapter-github/src/index.ts
@@ -20,6 +20,7 @@ import type {
StreamOptions,
Thread,
ThreadInfo,
+ UserInfo,
WebhookOptions,
} from "chat";
import { ConsoleLogger, convertEmojiPlaceholders, Message } from "chat";
@@ -378,6 +379,26 @@ export class GitHubAdapter
return this.getStoredInstallationId(owner, repo);
}
+ async getUser(userId: string): Promise {
+ try {
+ const { data: user } = await this.getOctokit().request(
+ "GET /user/{account_id}",
+ { account_id: Number(userId) }
+ );
+ return {
+ avatarUrl: user.avatar_url,
+ email: user.email ?? undefined,
+ fullName: user.name || user.login,
+ isBot: user.type === "Bot",
+ userId: String(user.id),
+ userName: user.login,
+ };
+ } catch (error) {
+ this.logger.debug("Failed to fetch user", { userId, error });
+ return null;
+ }
+ }
+
/**
* Handle incoming webhook from GitHub.
*/
diff --git a/packages/adapter-linear/src/index.test.ts b/packages/adapter-linear/src/index.test.ts
index 4e6cda8e..3262a236 100644
--- a/packages/adapter-linear/src/index.test.ts
+++ b/packages/adapter-linear/src/index.test.ts
@@ -3705,3 +3705,86 @@ describe("createLinearAdapter", () => {
);
});
});
+
+describe("getUser", () => {
+ it("should return user info from Linear API", async () => {
+ const adapter = createWebhookAdapter();
+ (adapter as any).defaultClient = {
+ user: vi.fn().mockResolvedValue({
+ id: "user-123",
+ name: "Alice Smith",
+ displayName: "alice",
+ email: "alice@example.com",
+ avatarUrl: "https://example.com/alice.png",
+ }),
+ };
+
+ const user = await adapter.getUser("user-123");
+ expect(user).not.toBeNull();
+ expect(user?.fullName).toBe("Alice Smith");
+ expect(user?.userName).toBe("alice");
+ expect(user?.email).toBe("alice@example.com");
+ expect(user?.avatarUrl).toBe("https://example.com/alice.png");
+ expect(user?.isBot).toBe(false);
+ });
+
+ it("should return null on error", async () => {
+ const adapter = createWebhookAdapter();
+ (adapter as any).defaultClient = {
+ user: vi.fn().mockRejectedValue(new Error("Not found")),
+ };
+
+ const user = await adapter.getUser("unknown");
+ expect(user).toBeNull();
+ });
+
+ it("should include userId in the response", async () => {
+ const adapter = createWebhookAdapter();
+ (adapter as any).defaultClient = {
+ user: vi.fn().mockResolvedValue({
+ id: "user-123",
+ name: "Alice Smith",
+ displayName: "alice",
+ email: "alice@example.com",
+ avatarUrl: "https://example.com/alice.png",
+ }),
+ };
+
+ const user = await adapter.getUser("user-123");
+ expect(user).not.toBeNull();
+ expect(user?.userId).toBe("user-123");
+ });
+
+ it("should call Linear SDK with the correct user ID", async () => {
+ const adapter = createWebhookAdapter();
+ const userMock = vi.fn().mockResolvedValue({
+ id: "user-123",
+ name: "Alice Smith",
+ displayName: "alice",
+ email: "alice@example.com",
+ avatarUrl: "https://example.com/alice.png",
+ });
+ (adapter as any).defaultClient = { user: userMock };
+
+ await adapter.getUser("user-123");
+ expect(userMock).toHaveBeenCalledWith("user-123");
+ });
+
+ it("should return undefined for null optional fields", async () => {
+ const adapter = createWebhookAdapter();
+ (adapter as any).defaultClient = {
+ user: vi.fn().mockResolvedValue({
+ id: "user-456",
+ name: "Bob",
+ displayName: "bob",
+ email: null,
+ avatarUrl: null,
+ }),
+ };
+
+ const user = await adapter.getUser("user-456");
+ expect(user).not.toBeNull();
+ expect(user?.email).toBeUndefined();
+ expect(user?.avatarUrl).toBeUndefined();
+ });
+});
diff --git a/packages/adapter-linear/src/index.ts b/packages/adapter-linear/src/index.ts
index 961da795..d09acb08 100644
--- a/packages/adapter-linear/src/index.ts
+++ b/packages/adapter-linear/src/index.ts
@@ -25,6 +25,7 @@ import type {
StreamChunk,
StreamOptions,
ThreadInfo,
+ UserInfo,
WebhookOptions,
} from "chat";
import { ConsoleLogger, Message, StreamingMarkdownRenderer } from "chat";
@@ -445,6 +446,23 @@ export class LinearAdapter
this.logger.info("Linear installation deleted", { organizationId });
}
+ async getUser(userId: string): Promise {
+ try {
+ await this.ensureValidToken();
+ const user = await this.getClient().user(userId);
+ return {
+ avatarUrl: user.avatarUrl ?? undefined,
+ email: user.email ?? undefined,
+ fullName: user.name,
+ isBot: false,
+ userId: user.id,
+ userName: user.displayName,
+ };
+ } catch {
+ return null;
+ }
+ }
+
/**
* Handle the Linear OAuth callback.
* Accepts the incoming request, extracts the authorization code,
diff --git a/packages/adapter-slack/src/index.test.ts b/packages/adapter-slack/src/index.test.ts
index 41e593f8..1f138209 100644
--- a/packages/adapter-slack/src/index.test.ts
+++ b/packages/adapter-slack/src/index.test.ts
@@ -5190,3 +5190,211 @@ describe("reverse user lookup", () => {
});
});
});
+
+describe("getUser", () => {
+ it("should return user info with email and avatar", async () => {
+ const state = createMockState();
+ const chatInstance = createMockChatInstance(state);
+ const adapter = createSlackAdapter({
+ botToken: "xoxb-test-token",
+ signingSecret: "test-secret",
+ logger: mockLogger,
+ });
+ await adapter.initialize(chatInstance);
+
+ mockClientMethod(
+ adapter,
+ "users.info",
+ vi.fn().mockResolvedValue({
+ ok: true,
+ user: {
+ is_bot: false,
+ name: "alice",
+ real_name: "Alice Smith",
+ profile: {
+ display_name: "Alice",
+ real_name: "Alice Smith",
+ email: "alice@example.com",
+ image_192: "https://example.com/alice.png",
+ },
+ },
+ })
+ );
+
+ const user = await adapter.getUser("U123456");
+ expect(user).not.toBeNull();
+ expect(user?.fullName).toBe("Alice Smith");
+ expect(user?.userName).toBe("Alice");
+ expect(user?.email).toBe("alice@example.com");
+ expect(user?.avatarUrl).toBe("https://example.com/alice.png");
+ expect(user?.isBot).toBe(false);
+ expect(user?.userId).toBe("U123456");
+ });
+
+ it("should return null when API fails", async () => {
+ const state = createMockState();
+ const chatInstance = createMockChatInstance(state);
+ const adapter = createSlackAdapter({
+ botToken: "xoxb-test-token",
+ signingSecret: "test-secret",
+ logger: mockLogger,
+ });
+ await adapter.initialize(chatInstance);
+
+ mockClientMethod(
+ adapter,
+ "users.info",
+ vi.fn().mockRejectedValue(new Error("API error"))
+ );
+
+ const user = await adapter.getUser("U_UNKNOWN");
+ expect(user).toBeNull();
+ });
+
+ it("should return null when user not found", async () => {
+ const state = createMockState();
+ const chatInstance = createMockChatInstance(state);
+ const adapter = createSlackAdapter({
+ botToken: "xoxb-test-token",
+ signingSecret: "test-secret",
+ logger: mockLogger,
+ });
+ await adapter.initialize(chatInstance);
+
+ mockClientMethod(
+ adapter,
+ "users.info",
+ vi.fn().mockRejectedValue(new Error("user_not_found"))
+ );
+
+ const user = await adapter.getUser("U_NOTFOUND");
+ expect(user).toBeNull();
+ });
+
+ it("should return isBot true for bot users", async () => {
+ const state = createMockState();
+ const chatInstance = createMockChatInstance(state);
+ const adapter = createSlackAdapter({
+ botToken: "xoxb-test-token",
+ signingSecret: "test-secret",
+ logger: mockLogger,
+ });
+ await adapter.initialize(chatInstance);
+
+ mockClientMethod(
+ adapter,
+ "users.info",
+ vi.fn().mockResolvedValue({
+ ok: true,
+ user: {
+ is_bot: true,
+ name: "mybot",
+ real_name: "My Bot",
+ profile: {
+ display_name: "Bot",
+ real_name: "My Bot",
+ image_192: "https://example.com/bot.png",
+ },
+ },
+ })
+ );
+
+ const user = await adapter.getUser("UBOT123");
+ expect(user).not.toBeNull();
+ expect(user?.isBot).toBe(true);
+ });
+
+ it("should fall through to real_name when display_name is empty", async () => {
+ const state = createMockState();
+ const chatInstance = createMockChatInstance(state);
+ const adapter = createSlackAdapter({
+ botToken: "xoxb-test-token",
+ signingSecret: "test-secret",
+ logger: mockLogger,
+ });
+ await adapter.initialize(chatInstance);
+
+ mockClientMethod(
+ adapter,
+ "users.info",
+ vi.fn().mockResolvedValue({
+ ok: true,
+ user: {
+ is_bot: false,
+ name: "alice",
+ real_name: "Alice Smith",
+ profile: {
+ display_name: "",
+ real_name: "Alice Smith",
+ email: "alice@example.com",
+ image_192: "https://example.com/alice.png",
+ },
+ },
+ })
+ );
+
+ const user = await adapter.getUser("U_PARTIAL");
+ expect(user).not.toBeNull();
+ expect(user?.fullName).toBe("Alice Smith");
+ expect(user?.userName).toBe("Alice Smith");
+ });
+
+ it("should call users.info with correct user ID", async () => {
+ const state = createMockState();
+ const chatInstance = createMockChatInstance(state);
+ const adapter = createSlackAdapter({
+ botToken: "xoxb-test-token",
+ signingSecret: "test-secret",
+ logger: mockLogger,
+ });
+ await adapter.initialize(chatInstance);
+
+ const usersInfoMock = vi.fn().mockResolvedValue({
+ ok: true,
+ user: {
+ is_bot: false,
+ name: "alice",
+ real_name: "Alice",
+ profile: {
+ display_name: "Alice",
+ real_name: "Alice",
+ },
+ },
+ });
+ mockClientMethod(adapter, "users.info", usersInfoMock);
+
+ await adapter.getUser("U_VERIFY");
+ expect(usersInfoMock).toHaveBeenCalledWith(
+ expect.objectContaining({ user: "U_VERIFY" })
+ );
+ });
+
+ it("should return cached user without hitting API", async () => {
+ const state = createMockState();
+ const chatInstance = createMockChatInstance(state);
+ const adapter = createSlackAdapter({
+ botToken: "xoxb-test-token",
+ signingSecret: "test-secret",
+ logger: mockLogger,
+ });
+ await adapter.initialize(chatInstance);
+
+ state.cache.set("slack:user:U_CACHED", {
+ avatarUrl: "https://example.com/cached.png",
+ displayName: "Cached User",
+ email: "cached@example.com",
+ isBot: false,
+ realName: "Cached User Full",
+ });
+
+ const usersInfoMock = vi.fn();
+ mockClientMethod(adapter, "users.info", usersInfoMock);
+
+ const user = await adapter.getUser("U_CACHED");
+ expect(user).not.toBeNull();
+ expect(user?.fullName).toBe("Cached User Full");
+ expect(user?.userName).toBe("Cached User");
+ expect(user?.email).toBe("cached@example.com");
+ expect(usersInfoMock).not.toHaveBeenCalled();
+ });
+});
diff --git a/packages/adapter-slack/src/index.ts b/packages/adapter-slack/src/index.ts
index 784a242a..11fbec5b 100644
--- a/packages/adapter-slack/src/index.ts
+++ b/packages/adapter-slack/src/index.ts
@@ -40,6 +40,7 @@ import type {
StreamOptions,
ThreadInfo,
ThreadSummary,
+ UserInfo,
WebhookOptions,
} from "chat";
@@ -363,7 +364,10 @@ type SlackInteractivePayload =
/** Cached user info */
interface CachedUser {
+ avatarUrl?: string;
displayName: string;
+ email?: string;
+ isBot?: boolean;
realName: string;
}
@@ -721,18 +725,16 @@ export class SlackAdapter implements Adapter {
/**
* Look up user info from Slack API with caching via state adapter.
- * Returns display name and real name, or falls back to user ID.
+ * Returns null when the API call fails.
*/
- private async lookupUser(
- userId: string
- ): Promise<{ displayName: string; realName: string }> {
+ private async lookupUser(userId: string): Promise {
const cacheKey = `slack:user:${userId}`;
// Check cache first (via state adapter for serverless compatibility)
if (this.chat) {
const cached = await this.chat.getState().get(cacheKey);
if (cached) {
- return { displayName: cached.displayName, realName: cached.realName };
+ return cached;
}
}
@@ -741,9 +743,15 @@ export class SlackAdapter implements Adapter {
this.withToken({ user: userId })
);
const user = result.user as {
+ is_bot?: boolean;
name?: string;
+ profile?: {
+ display_name?: string;
+ email?: string;
+ image_192?: string;
+ real_name?: string;
+ };
real_name?: string;
- profile?: { display_name?: string; real_name?: string };
};
// Slack user naming: profile.display_name > profile.real_name > real_name > name > userId
@@ -756,15 +764,19 @@ export class SlackAdapter implements Adapter {
const realName =
user?.real_name || user?.profile?.real_name || displayName;
+ const cached: CachedUser = {
+ avatarUrl: user?.profile?.image_192,
+ displayName,
+ email: user?.profile?.email,
+ isBot: user?.is_bot,
+ realName,
+ };
+
// Cache the result via state adapter
if (this.chat) {
await this.chat
.getState()
- .set(
- cacheKey,
- { displayName, realName },
- SlackAdapter.USER_CACHE_TTL_MS
- );
+ .set(cacheKey, cached, SlackAdapter.USER_CACHE_TTL_MS);
// Build reverse index: display name → user IDs (skip if already present)
const normalizedName = displayName.toLowerCase();
@@ -783,11 +795,10 @@ export class SlackAdapter implements Adapter {
displayName,
realName,
});
- return { displayName, realName };
+ return cached;
} catch (error) {
this.logger.warn("Could not fetch user info", { userId, error });
- // Fall back to user ID
- return { displayName: userId, realName: userId };
+ return null;
}
}
@@ -833,6 +844,25 @@ export class SlackAdapter implements Adapter {
}
}
+ async getUser(userId: string): Promise {
+ try {
+ const cached = await this.lookupUser(userId);
+ if (!cached) {
+ return null;
+ }
+ return {
+ avatarUrl: cached.avatarUrl,
+ email: cached.email,
+ fullName: cached.realName,
+ isBot: cached.isBot ?? false,
+ userId,
+ userName: cached.displayName,
+ };
+ } catch {
+ return null;
+ }
+ }
+
async handleWebhook(
request: Request,
options?: WebhookOptions
@@ -1044,8 +1074,8 @@ export class SlackAdapter implements Adapter {
text,
user: {
userId,
- userName: userInfo.displayName,
- fullName: userInfo.realName,
+ userName: userInfo?.displayName ?? userId,
+ fullName: userInfo?.realName ?? userId,
isBot: false,
isMe: false,
},
@@ -1773,7 +1803,7 @@ export class SlackAdapter implements Adapter {
Promise.all(
[...userIds].map(async (uid) => {
const info = await this.lookupUser(uid);
- return [uid, info.displayName] as const;
+ return [uid, info?.displayName ?? uid] as const;
})
),
Promise.all(
@@ -1913,8 +1943,8 @@ export class SlackAdapter implements Adapter {
// If we have a user ID but no username, look up the user info
if (event.user && !event.username) {
const userInfo = await this.lookupUser(event.user);
- userName = userInfo.displayName;
- fullName = userInfo.realName;
+ userName = userInfo?.displayName ?? event.user;
+ fullName = userInfo?.realName ?? userName;
}
// Track thread participants for outgoing mention resolution (skip dupes)
diff --git a/packages/adapter-telegram/src/index.test.ts b/packages/adapter-telegram/src/index.test.ts
index dfb8b28f..f7a5a232 100644
--- a/packages/adapter-telegram/src/index.test.ts
+++ b/packages/adapter-telegram/src/index.test.ts
@@ -1980,3 +1980,164 @@ describe("applyTelegramEntities", () => {
);
});
});
+
+describe("getUser", () => {
+ it("should return user info from Telegram getChat", async () => {
+ const adapter = createTelegramAdapter({
+ botToken: "token",
+ mode: "webhook",
+ logger: mockLogger,
+ });
+
+ // getMe for initialize
+ mockFetch.mockResolvedValueOnce(
+ telegramOk({
+ id: 999,
+ is_bot: true,
+ first_name: "Bot",
+ username: "mybot",
+ })
+ );
+ await adapter.initialize(createMockChat());
+
+ // getChat for getUser
+ mockFetch.mockResolvedValueOnce(
+ telegramOk({
+ id: 456,
+ first_name: "Alice",
+ last_name: "Smith",
+ username: "alicesmith",
+ type: "private",
+ })
+ );
+
+ const user = await adapter.getUser("456");
+ expect(user).not.toBeNull();
+ expect(user?.fullName).toBe("Alice Smith");
+ expect(user?.userName).toBe("alicesmith");
+ expect(user?.userId).toBe("456");
+ expect(user?.isBot).toBe(false);
+ expect(user?.email).toBeUndefined();
+ });
+
+ it("should return null on error", async () => {
+ const adapter = createTelegramAdapter({
+ botToken: "token",
+ mode: "webhook",
+ logger: mockLogger,
+ });
+
+ mockFetch.mockResolvedValueOnce(
+ telegramOk({
+ id: 999,
+ is_bot: true,
+ first_name: "Bot",
+ username: "mybot",
+ })
+ );
+ await adapter.initialize(createMockChat());
+
+ mockFetch.mockResolvedValueOnce(telegramError(400, 400, "Bad Request"));
+
+ const user = await adapter.getUser("unknown");
+ expect(user).toBeNull();
+ });
+
+ it("should return null for group/channel chat IDs", async () => {
+ const adapter = createTelegramAdapter({
+ botToken: "token",
+ mode: "webhook",
+ logger: mockLogger,
+ });
+
+ mockFetch.mockResolvedValueOnce(
+ telegramOk({
+ id: 999,
+ is_bot: true,
+ first_name: "Bot",
+ username: "mybot",
+ })
+ );
+ await adapter.initialize(createMockChat());
+
+ mockFetch.mockResolvedValueOnce(
+ telegramOk({
+ id: -100123,
+ type: "group",
+ title: "Test Group",
+ })
+ );
+
+ const user = await adapter.getUser("-100123");
+ expect(user).toBeNull();
+ });
+
+ it("should handle first-name only user (no last_name or username)", async () => {
+ const adapter = createTelegramAdapter({
+ botToken: "token",
+ mode: "webhook",
+ logger: mockLogger,
+ });
+
+ mockFetch.mockResolvedValueOnce(
+ telegramOk({
+ id: 999,
+ is_bot: true,
+ first_name: "Bot",
+ username: "mybot",
+ })
+ );
+ await adapter.initialize(createMockChat());
+
+ mockFetch.mockResolvedValueOnce(
+ telegramOk({
+ id: 789,
+ first_name: "Charlie",
+ type: "private",
+ })
+ );
+
+ const user = await adapter.getUser("789");
+ expect(user).not.toBeNull();
+ expect(user?.fullName).toBe("Charlie");
+ expect(user?.userName).toBe("Charlie");
+ });
+
+ it("should call Telegram API with correct method and params", async () => {
+ const adapter = createTelegramAdapter({
+ botToken: "token",
+ mode: "webhook",
+ logger: mockLogger,
+ });
+
+ mockFetch.mockResolvedValueOnce(
+ telegramOk({
+ id: 999,
+ is_bot: true,
+ first_name: "Bot",
+ username: "mybot",
+ })
+ );
+ await adapter.initialize(createMockChat());
+
+ mockFetch.mockResolvedValueOnce(
+ telegramOk({
+ id: 456,
+ first_name: "Alice",
+ username: "alice",
+ type: "private",
+ })
+ );
+
+ await adapter.getUser("456");
+
+ // The second fetch call (index 1) is the getChat call
+ const getChatUrl = String(mockFetch.mock.calls[1]?.[0]);
+ expect(getChatUrl).toContain("/getChat");
+
+ const getChatBody = JSON.parse(
+ String((mockFetch.mock.calls[1]?.[1] as RequestInit).body)
+ ) as { chat_id: string };
+ expect(getChatBody.chat_id).toBe("456");
+ });
+});
diff --git a/packages/adapter-telegram/src/index.ts b/packages/adapter-telegram/src/index.ts
index eb0e263d..771c5a67 100644
--- a/packages/adapter-telegram/src/index.ts
+++ b/packages/adapter-telegram/src/index.ts
@@ -23,6 +23,7 @@ import type {
Logger,
RawMessage,
ThreadInfo,
+ UserInfo,
WebhookOptions,
} from "chat";
import {
@@ -311,6 +312,32 @@ export class TelegramAdapter
}
}
+ async getUser(userId: string): Promise {
+ try {
+ const chat = await this.telegramFetch("getChat", {
+ chat_id: userId,
+ });
+ // Only private chats represent users — groups/channels are not user lookups
+ if (chat.type !== "private") {
+ return null;
+ }
+ const fullName = [chat.first_name, chat.last_name]
+ .filter(Boolean)
+ .join(" ");
+ return {
+ email: undefined,
+ fullName: fullName || String(chat.id),
+ // Telegram's getChat API doesn't expose is_bot (only available on TelegramUser).
+ // Always returns false — callers needing bot detection should use message.author.isBot instead.
+ isBot: false,
+ userId: String(chat.id),
+ userName: chat.username || chat.first_name || String(chat.id),
+ };
+ } catch {
+ return null;
+ }
+ }
+
async handleWebhook(
request: Request,
options?: WebhookOptions
diff --git a/packages/chat/src/chat.test.ts b/packages/chat/src/chat.test.ts
index e02dcfab..1d69c7aa 100644
--- a/packages/chat/src/chat.test.ts
+++ b/packages/chat/src/chat.test.ts
@@ -1450,6 +1450,63 @@ describe("Chat", () => {
});
});
+ describe("getUser", () => {
+ it("should return user info from adapter", async () => {
+ mockAdapter.getUser = vi.fn().mockResolvedValue({
+ userId: "U123456",
+ userName: "alice",
+ fullName: "Alice Smith",
+ email: "alice@example.com",
+ avatarUrl: "https://example.com/alice.png",
+ isBot: false,
+ });
+
+ const user = await chat.getUser("U123456");
+ expect(user).not.toBeNull();
+ expect(user?.email).toBe("alice@example.com");
+ expect(user?.fullName).toBe("Alice Smith");
+ expect(mockAdapter.getUser).toHaveBeenCalledWith("U123456");
+ });
+
+ it("should accept Author object", async () => {
+ mockAdapter.getUser = vi.fn().mockResolvedValue({
+ userId: "U789",
+ userName: "bob",
+ fullName: "Bob Jones",
+ isBot: false,
+ });
+
+ const user = await chat.getUser({
+ userId: "U789",
+ userName: "bob",
+ fullName: "Bob Jones",
+ isBot: false,
+ isMe: false,
+ });
+ expect(mockAdapter.getUser).toHaveBeenCalledWith("U789");
+ expect(user?.fullName).toBe("Bob Jones");
+ });
+
+ it("should throw when adapter does not support getUser", async () => {
+ await expect(chat.getUser("U123456")).rejects.toThrow(
+ "does not support getUser"
+ );
+ });
+
+ it("should return null when user is not found", async () => {
+ mockAdapter.getUser = vi.fn().mockResolvedValue(null);
+ const user = await chat.getUser("U999999");
+ expect(user).toBeNull();
+ });
+
+ it("should throw error for unknown userId format", async () => {
+ mockAdapter.getUser = vi.fn().mockResolvedValue(null);
+ await expect(chat.getUser("invalid-user-id")).rejects.toThrow(
+ 'Cannot infer adapter from userId "invalid-user-id"'
+ );
+ });
+ });
+
describe("isDM", () => {
it("should return true for DM threads", async () => {
const thread = await chat.openDM("U123456");
diff --git a/packages/chat/src/chat.ts b/packages/chat/src/chat.ts
index 5cac0b93..4b5949db 100644
--- a/packages/chat/src/chat.ts
+++ b/packages/chat/src/chat.ts
@@ -53,6 +53,7 @@ import type {
StateAdapter,
SubscribedMessageHandler,
Thread,
+ UserInfo,
WebhookOptions,
} from "./types";
import { ChatError, ConsoleLogger, LockError } from "./types";
@@ -1525,6 +1526,35 @@ export class Chat<
return this.createThread(adapter, threadId, {} as Message, false);
}
+ /**
+ * Look up user information by user ID.
+ *
+ * The adapter is automatically inferred from the user ID format.
+ * Returns user details including email (where available — requires
+ * appropriate scopes on some platforms, e.g. `users:read.email` on Slack).
+ *
+ * @param user - Platform-specific user ID string, or an Author object
+ * @returns User info, or null if user not found
+ *
+ * @example
+ * ```typescript
+ * const user = await chat.getUser("U123456");
+ * console.log(user?.email); // "alice@company.com"
+ * ```
+ */
+ async getUser(user: string | Author): Promise {
+ const userId = typeof user === "string" ? user : user.userId;
+ const adapter = this.inferAdapterFromUserId(userId);
+ if (!adapter.getUser) {
+ throw new ChatError(
+ `Adapter "${adapter.name}" does not support getUser`,
+ "NOT_SUPPORTED"
+ );
+ }
+
+ return adapter.getUser(userId);
+ }
+
/**
* Get a Channel by its channel ID.
*
diff --git a/packages/chat/src/index.ts b/packages/chat/src/index.ts
index fc27fa95..c9b0861d 100644
--- a/packages/chat/src/index.ts
+++ b/packages/chat/src/index.ts
@@ -354,6 +354,7 @@ export type {
Thread,
ThreadInfo,
ThreadSummary,
+ UserInfo,
WebhookOptions,
WellKnownEmoji,
} from "./types";
diff --git a/packages/chat/src/types.ts b/packages/chat/src/types.ts
index d17e7c65..ed4e8a2f 100644
--- a/packages/chat/src/types.ts
+++ b/packages/chat/src/types.ts
@@ -316,6 +316,15 @@ export interface Adapter {
*/
getChannelVisibility?(threadId: string): ChannelVisibility;
+ /**
+ * Look up user information by user ID.
+ * Optional — not all platforms support this.
+ *
+ * @param userId - Platform-specific user ID
+ * @returns User info, or null if user not found
+ */
+ getUser?(userId: string): Promise;
+
/** Handle incoming webhook request */
handleWebhook(request: Request, options?: WebhookOptions): Promise;
@@ -1341,6 +1350,22 @@ export interface Author {
userName: string;
}
+/** User information returned by adapter.getUser() */
+export interface UserInfo {
+ /** URL to the user's avatar/profile image */
+ avatarUrl?: string;
+ /** User's email address (requires appropriate scopes on some platforms) */
+ email?: string;
+ /** User's display name / full name */
+ fullName: string;
+ /** Whether the user is a bot */
+ isBot: boolean;
+ /** Platform-specific user ID */
+ userId: string;
+ /** Username/handle */
+ userName: string;
+}
+
export interface MessageMetadata {
/** When the message was sent */
dateSent: Date;