Messages
Everything here except the lookups needs a loaded identity.
Methods that take a recipient or target accept a numeric user id, a @handle, a { id } object or a conversation id. Handles are resolved once and cached. Use the conversation id (g...) for groups and for the chat with yourself.
Looking up users
These only need a logged-in session, no identity.
xchat.publicKey(userId)
A user's newest XChat identity key, or null if they aren't on XChat.
const key = await client.xchat.publicKey("44196397");
// { version, publicKey, signingPublicKey, identityPublicKeySignature, registrationMethod }publicKey is the key others wrap conversation keys to, and signingPublicKey verifies the user's message signatures (see encryption).
xchat.publicKeys(userIds, opts?)
Profiles for one or more users in one request, in the shape profile returns. opts.includeTokens asks Twitter for the Juicebox realm token maps too.
const users = await client.xchat.publicKeys(["44196397", "tiagozip_"]);xchat.profile(userId)
The parsed XChat profile: { userId, onXChat, isManagedPinUser, permissions, keys }.
const p = await client.xchat.profile("44196397");xchat.permissions(userId)
The permission flags { canDm, canDmOnXChat, dmBlocking, passesPremiumCheck }. canDmOnXChat is about you: whether you can message this user over XChat.
const perms = await client.xchat.permissions("44196397");xchat.canMessage(userId)
true when permissions().canDmOnXChat is set.
if (await client.xchat.canMessage("44196397")) console.log("reachable");xchat.isOnXChat(userId)
Whether the user has published an XChat key.
await client.xchat.isOnXChat("44196397"); // truexchat.fingerprint(key)
A SHA-256 fingerprint of a key, in colon-separated groups of four hex digits. Pass a key object from publicKey or a raw SPKI base64 string, then compare it with the other person out of band.
await client.xchat.fingerprint(await client.xchat.publicKey("44196397"));
// "8435:8166:a7fb:5c30:68dc:df90:dd47:b519:9eff:588c:c2bd:0772:36aa:c127:8e4b:b1c6"xchat.callPermissions(userIds)
Whether you may call one or more users. Returns Twitter's per-user result, { can_dm, error_code }.
await client.xchat.callPermissions("44196397");Sending messages
Every send is end-to-end encrypted and signed for you. Sends to a group reuse the group's newest conversation key. If a 1:1 chat has no key you can read, or a member's key changed, emusks creates a new one.
const { sequenceId } = await client.xchat.message("tiagozip_", "hey, encrypted ^w^");
await client.xchat.react("tiagozip_", sequenceId, "🔥");Every send method returns { conversationId, recipientId, messageId, sequenceId, response } and throws when Twitter rejects the event. The error's code is Twitter's reason, such as RECIPIENT_KEY_HAS_CHANGED or TOO_MANY_MESSAGES.
xchat.message(recipient, text, opts?)
Sends a text message. Keep the returned sequenceId to react to, edit or delete it.
const sent = await client.xchat.message("44196397", "hello!");
await client.xchat.message(group.conversationId, "this one", { replyTo: sent.sequenceId });
await client.xchat.message("44196397", "look", { media: [await Bun.file("cat.jpg").bytes()] });| Option | Type | Description |
|---|---|---|
replyTo | string | object | A sequence id, or a decoded message from read() |
media | array | Files to encrypt, upload and attach: Uint8Array, Buffer, ArrayBuffer, Blob, a file path, or { data, contentType?, filename?, width?, height? } |
attachments | array | Attachments you already uploaded with uploadMedia, or post, url and card attachments |
ttlMsec | number | Disappearing-message lifetime |
shouldNotify | boolean | Whether the recipient gets a notification. Default true. |
messageId | string | Your own message id. Default is a random UUID. |
A reply carries a preview of the original (sender, text, attachments, sequence id and message id), which other clients show above the reply. When replyTo is a sequence id, emusks fetches the original with events() first. With replyTo, media or attachments, the result also has attachments and replyTo.
xchat.uploadMedia(target, file, opts?)
Encrypts a file with the conversation key and uploads it in 1 MiB parts, the way the web client does. It returns a media attachment (mediaHashKey, mediaType, width, height, size, filename, attachmentId, messageId, ...) to pass to message() in attachments. Twitter ties the upload to a message id, so pass the same opts.messageId to both calls. message() with media does all of this for you.
const messageId = crypto.randomUUID();
const photo = await client.xchat.uploadMedia("44196397", "./cat.png", { messageId });
await client.xchat.message("44196397", "", { messageId, attachments: [photo] });emusks reads image dimensions from PNG, JPEG, GIF and WebP headers. Pass width and height in the file object for anything else.
xchat.react(recipient, messageSequenceId, emoji, opts?)
Reacts to a message. opts.attachmentId targets one attachment of the message.
await client.xchat.react("44196397", sequenceId, "🔥");xchat.unreact(recipient, messageSequenceId, emoji, opts?)
Removes a reaction you added.
await client.xchat.unreact("44196397", sequenceId, "🔥");xchat.edit(recipient, messageSequenceId, newText, opts?)
Edits one of your messages.
await client.xchat.edit("44196397", sequenceId, "edited text");xchat.markRead(recipient, sequenceId, opts?)
Marks a conversation read up to a sequence id. opts.seenAtMillis sets the timestamp, which defaults to now.
await client.xchat.markRead("44196397", sequenceId);xchat.markUnread(recipient, sequenceId, opts?)
Marks a conversation unread from a sequence id.
await client.xchat.markUnread("44196397", sequenceId);xchat.deleteMessages(recipient, sequenceIds, opts?)
Deletes one or more messages from your own view. opts.forEveryone: true unsends them for everyone, which needs a loaded identity to sign the request. Returns Twitter's { error_code } result.
await client.xchat.deleteMessages("44196397", [sequenceId]);
await client.xchat.deleteMessages("44196397", [sequenceId], { forEveryone: true });xchat.typing(recipient, opts?)
Sends a signed typing indicator over the chat WebSocket. emusks opens a socket with socket() on first use and reuses it, or you can pass your own as opts.socket. opts.activity defaults to "typing".
await client.xchat.typing("44196397");Full example
import Emusks from "emusks";
import { existsSync, readFileSync, writeFileSync } from "node:fs";
const client = new Emusks();
await client.login("your_auth_token");
if (existsSync("xchat-identity.json")) {
await client.xchat.loadIdentity(JSON.parse(readFileSync("xchat-identity.json", "utf8")));
} else {
writeFileSync("xchat-identity.json", JSON.stringify(await client.xchat.createIdentity({ pin: "2468" })));
}
if (await client.xchat.canMessage("tiagozip_")) {
const { sequenceId } = await client.xchat.message("tiagozip_", "I am Charlie Kirk");
await client.xchat.react("tiagozip_", sequenceId, "✅");
}Reading messages
Reading decrypts with your private key, so it needs a loaded identity.
for (const convo of await client.xchat.conversations()) {
console.log(convo.conversationId, convo.type, convo.groupName, convo.latestMessage?.text);
}
const { messages } = await client.xchat.read("tiagozip_");
for (const m of messages) {
if (m.kind === "message") console.log(m.senderId, m.text, m.attachments.length);
}xchat.inbox(opts?)
One page of the inbox, newest first. Returns { conversations, cursor, requestsCount }. Pass cursor back as opts.cursor for the next page. It is null on the last page. requestsCount is the number of pending message requests.
let page = await client.xchat.inbox();
while (page.cursor) page = await client.xchat.inbox({ cursor: page.cursor });Each conversation has:
| Field | Description |
|---|---|
conversationId | minId:maxId for 1:1 chats, g<digits> for groups |
type | "direct" or "group" |
self | true for the chat with yourself (id:id) |
participants | Member user ids. For 1:1 chats this is the other person, or just you in the self chat. |
groupName | Group name, decrypted when the group encrypts it |
groupNameSealed | true when the name is encrypted with a key you don't have |
avatarUrl | Group avatar. Encrypted groups store it on ton.x.com; fetch it with media(). |
adminIds, removedIds | Group admins and removed members |
createdAt, ttlMs, inviteUrl | Group creation time, disappearing-message lifetime, invite link |
isMuted | Whether you muted it |
deleted | Whether you deleted it |
latestSequenceId | Sequence id of the newest event |
latestMessage | The newest decoded event, of any kind |
messages | Every decoded event the inbox returned for it, oldest first |
reads | { userId: seenUntilSequenceId } read receipts |
lastReadSequenceId | Your own read position |
unread | true when someone else sent a message after your read position |
xchat.conversations(opts?)
The conversations array of one inbox page. Same as (await inbox(opts)).conversations.
const convos = await client.xchat.conversations();xchat.read(target, opts?)
Reads and decrypts one page of a conversation. Pass opts.before (a sequence id) to page into older history. Returns { conversationId, type, hasMore, requestState, messages, keys }.
const group = (await client.xchat.conversations()).find((c) => c.type === "group");
const { messages, hasMore } = await client.xchat.read(group.conversationId);Every message has sequenceId, messageId, senderId, conversationId, createdAt (milliseconds, as a string) and kind. Encrypted events also have keyVersion. When the key is missing the entry has encrypted: true, and when decryption fails it has decryptError: true.
kind | Extra fields |
|---|---|
message | text, entities, attachments, replyTo, forwarded, sentFrom, ctas |
reaction_add, reaction_remove | targetSequenceId, emoji, targetAttachmentId |
edit | targetSequenceId, text, entities |
delete | sequenceIds, forEveryone |
read, unread | seenUntilSequenceId, seenAt |
mark_read, mark_unread | the same, sent as an encrypted entry by some clients |
av_call_started, av_call_ended, av_call_missed | audioOnly, broadcastId, durationSeconds |
accept_request | none. The other side accepted your message request. |
nickname | userId, nickname |
draft | text, a draft synced from your other devices |
screen_capture | captureType |
pin_conversation, unpin_conversation | targetConversationId |
user_metadata | pinnedConversationIds, nicknames, verifiedUserIds (only in the self chat) |
conversation_key_change | keyVersion, participantIds |
metadata_change | change (mute, unmute, ttl_change, screen_capture_blocking_on, ...), ttlMs |
group_create | memberIds, adminIds, title, avatarUrl |
group_member_add | memberIds, currentMemberIds, currentAdminIds, title, createdBy |
group_member_remove | memberIds |
group_admin_add, group_admin_remove | adminIds |
group_rename | title, titleSealed |
group_avatar | avatarUrl |
group_description | description |
group_invite_enable, group_invite_disable, group_join_request, group_join_reject | invite details |
member_account_delete | memberId |
typing, failure, conversation_delete, pin_message, unpin_message, grok_response | event details |
Attachments in m.attachments:
type | Fields |
|---|---|
media | mediaType (image, gif, video, audio, file, svg), mediaHashKey, width, height, durationMs, size, filename, attachmentId, encrypted, legacyUrl |
post | postId, url |
url | url, title, banner, favicon |
card | url |
money, jetfuel, payment_receipt, bot | raw details |
replyTo is the preview the sender embedded: { sequenceId, messageId, senderId, senderName, text, attachments, attachmentId, call }. entities are { start, end, type } ranges, where type is url, mention, hashtag, cashtag, email, address or phone.
TIP
Like the official client, Twitter doesn't send your own new messages back to a fresh client right away. Keep the results of message() if you need your outgoing history immediately.
xchat.events(sequenceIds)
Fetches and decrypts specific events by sequence id, from any conversation. Returns { messages, notFound }.
const { messages } = await client.xchat.events([m.replyTo.sequenceId]);xchat.media(target, attachment, opts?)
Downloads an attachment and decrypts it. attachment is a decoded media attachment or a URL string, such as a group's avatarUrl. Pass the message's keyVersion on the attachment or as opts.keyVersion so emusks tries that key first. Returns { bytes, contentType, size, encrypted }.
const m = messages.find((m) => m.attachments?.some((a) => a.type === "media"));
const photo = m.attachments.find((a) => a.type === "media");
const { bytes, contentType } = await client.xchat.media(m.conversationId, { ...photo, keyVersion: m.keyVersion });Media migrated from legacy DMs has a legacyUrl and is fetched without decryption.
Conversations
xchat.pin(recipient, opts?)
Pins a conversation. Pins sync across your own devices.
await client.xchat.pin("44196397");xchat.unpin(recipient, opts?)
Unpins a conversation.
await client.xchat.unpin("44196397");xchat.setNickname(recipient, nickname, opts?)
Sets a nickname inside a conversation. It names the recipient unless you pass opts.userId to name another participant.
await client.xchat.setNickname("44196397", "the boss");xchat.reportScreenCapture(recipient, opts?)
Sends the "screenshot detected" notice the official app sends when you screenshot a chat. opts.recording: true reports a screen recording instead. Idk why you'd need this but it's here.
await client.xchat.reportScreenCapture("44196397");Message requests
Chats from people you don't follow start as message requests.
xchat.requests(opts?)
One page of pending requests. Returns { requests, total, cursor }. Pass cursor back as opts.cursor for more. opts.type narrows the list to "YouMayKnow" or "LowQuality".
const { requests, total } = await client.xchat.requests();| Field | Description |
|---|---|
conversationId | The conversation the request belongs to |
type | "direct" or "group" |
requestType | Twitter's bucket for it, such as YouMayKnow |
participants | Who is asking, from the conversation id and the message senders |
latestMessage, messages | Decoded events, in the same shape as read() |
socialProof | { id, name, screenName, avatar } of people you follow who know them |
xchat.acceptRequest(target, opts?)
Accepts a request. If the conversation has no key you can read yet, emusks adds a new one in the same call. For group requests, pass opts.userId, the id of whoever added you.
await client.xchat.acceptRequest(requests[0].conversationId);xchat.declineRequest(target)
Declines a request by deleting the conversation with a signed delete event.
await client.xchat.declineRequest(requests[0].conversationId);