with one click
writing-methods
// Use when adding or modifying high-level client methods in packages/core/src/highlevel/methods/. Covers function signatures, codegen annotations, peer resolution, update handling, pagination, and all common patterns.
// Use when adding or modifying high-level client methods in packages/core/src/highlevel/methods/. Covers function signatures, codegen annotations, peer resolution, update handling, pagination, and all common patterns.
Use when updating TL schema layer in mtcute, fetching new Telegram API schemas, or when user asks to update the layer/schema
Use when working with mtcute, @mtcute/* packages, Telegram MTProto in TypeScript, TL types, Telegram API methods, or building Telegram bots/clients in TypeScript. Not for Bot API wrapper libraries (grammy, telegraf, node-telegram-bot-api).
| name | writing-methods |
| description | Use when adding or modifying high-level client methods in packages/core/src/highlevel/methods/. Covers function signatures, codegen annotations, peer resolution, update handling, pagination, and all common patterns. |
| metadata | {"internal":true} |
Guide for adding/modifying methods in packages/core/src/highlevel/methods/.
Also read the using-mtcute skill for more information.
Before writing a method, look up the TL constructor(s) you need:
node .claude/skills/using-mtcute/tools/get-constructor.ts <name>
# e.g. node packages/tl/scripts/get-constructor.ts messages.sendMessage
This prints TL definition, TypeScript type, union info, and return type.
Every exported method function follows this pattern:
import type { ITelegramClient } from '../../client.types.js'
export async function methodName(
client: ITelegramClient, // always first arg
primaryId: InputPeerLike, // positional: primary identifiers
secondaryId: number, // positional: other required args
params?: { // trailing optional params object
optionalField?: SomeType
},
): Promise<ReturnType> { ... }
Rules:
client: ITelegramClient (stripped by codegen for the class method)params? objectparams object (see editAdminRights)isolatedDeclarations: true)AsyncIterableIterator<T> return typeFiles are processed by packages/core/scripts/generate-client.cjs. Annotations are // @annotation comments on the line before the statement.
// @copyCopies the import/statement into generated client.ts. Used ONLY in _imports.ts for shared imports.
// @copy
import { tl } from '../../tl/index.js'
// @exportedRe-exports a type from the generated methods.ts. Use for params interfaces and offset types.
// @exported
export interface DeleteMessagesParams { ... }
// @available=user|bot|bothControls the **Available**: JSDoc annotation. If absent, auto-detected from which TL methods are called. Do not add on your own.
// @alias=name1,name2Creates additional class method aliases.
// @alias=deleteSupergroup
export async function deleteChannel(...) { ... }
// @skipExcludes from client codegen entirely.
// @internal + // @noemit@internal marks as private in generated interface. @noemit excludes from methods.ts re-export. Combine for internal helpers.
/**
* @internal
* @noemit
*/
export function _findMessageInUpdate(...) { ... }
node packages/core/scripts/generate-client.cjs
import { resolvePeer, resolveUser, resolveChannel } from '../users/resolve-peer.js'
// General peer resolution → tl.TypeInputPeer
const peer = await resolvePeer(client, chatId)
// Type-specific shortcuts
const user = await resolveUser(client, userId) // → tl.TypeInputUser
const channel = await resolveChannel(client, chatId) // → tl.TypeInputChannel
InputPeerLike accepts: marked peer ID (number), username (string), "me"/"self", TL peer objects, or high-level User/Chat objects.
import { isInputPeerChannel, toInputChannel } from '../../utils/peer-utils.js'
const peer = await resolvePeer(client, chatId)
if (isInputPeerChannel(peer)) {
await client.call({ _: 'channels.deleteMessages', channel: toInputChannel(peer), id: ids })
} else {
await client.call({ _: 'messages.deleteMessages', id: ids, revoke })
}
Other peer utilities: isInputPeerChat, isInputPeerUser, getMarkedPeerId, parseMarkedPeerId.
For wrong peer type: throw new MtInvalidPeerTypeError(peerId, 'chat or channel').
import { _getUsersBatched, _getChatsBatched, _getChannelsBatched } from '../chats/batched-queries.js'
const user = await _getUsersBatched(client, toInputUser(peer))
Coalesces multiple individual requests into single users.getUsers/messages.getChats/channels.getChannels calls.
tl.TypeUpdatesconst res = await client.call({ _: 'channels.editAdmin', ... })
client.handleClientUpdate(res)
Pass noDispatch to suppress update dispatch:
client.handleClientUpdate(res, true) // sync PTS only, don't dispatch
import { _findMessageInUpdate } from './find-in-update.js'
// For send methods:
const msg = _findMessageInUpdate(client, res, false, !params.shouldDispatch, false, randomId)
// For edit methods (isEdit=true):
const msg = _findMessageInUpdate(client, res, true, !params.shouldDispatch)
// Nullable variant:
const msg = _findMessageInUpdate(client, res, false, !params.shouldDispatch, true)
messages.affectedMessages / messages.affectedHistoryThese carry PTS but are not Updates objects. Create a dummy update:
import { createDummyUpdate } from '../../updates/utils.js'
const res = await client.call({ _: 'channels.deleteMessages', channel, id: ids })
const upd = createDummyUpdate(res.pts, res.ptsCount, peer.channelId) // channelId for channel-scoped PTS
client.handleClientUpdate(upd)
// For non-channel:
const upd = createDummyUpdate(res.pts, res.ptsCount)
shouldDispatch patternMethods that return updates typically accept shouldDispatch?: true in params. The convention is to NOT dispatch by default (pass !params.shouldDispatch to noDispatch):
client.handleClientUpdate(res, !params.shouldDispatch)
// or for _findMessageInUpdate:
_findMessageInUpdate(client, res, false, !params.shouldDispatch)
import { _normalizeInputText } from '../misc/normalize-text.js'
const [message, entities] = await _normalizeInputText(client, text)
InputText is string | { text: string, entities: tl.TypeMessageEntity[] }. Handles mention entity resolution and whitespace trimming.
Some methods require passing a 2FA password, inside the InputCheckPasswordSRP type.
You are expected to add a password field to the params object, and use it as follows:
const password = await client.computeSrpParams(
await client.call({
_: 'account.getPassword',
}),
params.password,
)
Common send methods use _processCommonSendParameters and _maybeInvokeWithBusinessConnection:
import { _processCommonSendParameters, CommonSendParams } from './send-common.js'
import { _maybeInvokeWithBusinessConnection } from './_business-connection.js'
import { randomLong } from '../../../utils/long-utils.js'
const { peer, replyTo, scheduleDate, chainId, quickReplyShortcut } = await _processCommonSendParameters(client, chatId, params)
const randomId = randomLong()
const res = await _maybeInvokeWithBusinessConnection(
client,
params.businessConnectionId,
{
_: 'messages.sendMessage',
peer,
replyTo,
randomId,
scheduleDate,
message,
entities,
silent: params.silent,
clearDraft: params.clearDraft,
noforwards: params.forbidForwards,
sendAs: params.sendAs ? await resolvePeer(client, params.sendAs) : undefined,
quickReplyShortcut,
effect: params.effect,
allowPaidFloodskip: params.allowPaidFloodskip,
allowPaidStars: params.allowPaidMessages,
},
{ chainId, abortSignal: params.abortSignal },
)
import { _getPeerChainId } from '../misc/chain-id.js'
const chainId = _getPeerChainId(client, peer, 'send')
await client.call(request, { chainId })
// InputFileLike → tl.TypeInputFile (triggers upload if needed)
const file = await client._normalizeInputFile(input, params)
// InputMediaLike → tl.TypeInputMedia
const media = await client._normalizeInputMedia(media, params)
ArrayPaginatedimport { makeArrayPaginated, ArrayPaginated } from '../../utils/index.js'
// @exported
export interface GetHistoryOffset { id: number; date: number }
export async function getHistory(
client: ITelegramClient,
chatId: InputPeerLike,
params?: { limit?: number; offset?: GetHistoryOffset },
): Promise<ArrayPaginated<Message, GetHistoryOffset>> {
// ...fetch and parse...
const last = msgs[msgs.length - 1]
const next = last ? { id: last.id, date: last.raw.date } : undefined
return makeArrayPaginated(msgs, res.count ?? msgs.length, next)
}
ArrayPaginated<T, Offset> extends Array<T> with .next (next offset or undefined) and .total.
export async function* iterHistory(
client: ITelegramClient,
chatId: InputPeerLike,
params?: Parameters<typeof getHistory>[2] & {
limit?: number // default Infinity
chunkSize?: number // default 100
},
): AsyncIterableIterator<Message> {
const peer = await resolvePeer(client, chatId) // resolve once
let { offset } = params ?? {}
let current = 0
for (;;) {
const res = await getHistory(client, peer, {
offset,
limit: Math.min(chunkSize, limit - current),
})
for (const msg of res) {
yield msg
if (++current >= limit) return
}
if (!res.next) return
offset = res.next
}
}
ArrayWithTotal for non-paginated responses with countimport { makeArrayWithTotal } from '../../utils/index.js'
return makeArrayWithTotal(items, total)
Wrap raw TL objects in high-level classes:
import { Message, PeersIndex } from '../../types/index.js'
// Build peer index from response
const peers = PeersIndex.from(res)
// Single message
return new Message(res.message, peers)
// Array
return res.users.map(u => new User(u))
// Nullable
return res ? new Chat(res) : null
// Filter empties
const msgs = res.messages.filter(m => m._ !== 'messageEmpty').map(m => new Message(m, peers))
import { MtArgumentError } from '../../../types/errors.js'
import { MtTypeAssertionError } from '../../../types/errors.js'
import { MtInvalidPeerTypeError } from '../../types/errors.js'
import { MtMessageNotFoundError } from '../../types/errors.js'
import { assertTypeIs, assertTypeIsNot } from '../../../utils/type-assertions.js'
// Type assertions on TL responses
assertTypeIsNot('getHistory', res, 'messages.messagesNotModified')
assertTypeIs('getFullUser', res.fullUser, 'userFull')
// Argument validation
throw new MtArgumentError('mustReply used, but replyTo was not passed')
// Peer type mismatch
throw new MtInvalidPeerTypeError(chatId, 'channel')
import { randomLong } from '../../../utils/long-utils.js'
import { normalizeDate, normalizeMessageId } from '../../utils/index.js'
import { getMarkedPeerId, parseMarkedPeerId } from '../../../utils/peer-utils.js'
import { inputPeerToPeer } from '../../utils/peer-utils.js'
import { Long } from 'long'
randomLong() — random Long for randomId fieldsnormalizeDate(d) — Date | number | undefined → UNIX secondsnormalizeMessageId(m) — number | Message | undefined → number | undefinedLong.ZERO — for hash fields (from long package)Optional boolean TL flags → optional params:
{
_: 'messages.sendMessage',
noWebpage: params.disableWebPreview, // undefined = flag not set
silent: params.silent,
clearDraft: params.clearDraft,
noforwards: params.forbidForwards,
}
Optional peer params with ternary:
sendAs: params.sendAs ? await resolvePeer(client, params.sendAs) : undefined,
Destructure defaults at top:
const { limit = 100, offset = 0 } = params ?? {}
send-text.ts, get-history.ts, delete-messages.ts)_, annotated @internal @noemit (_processCommonSendParameters, _findMessageInUpdate)// @exported from the relevant file_normalize* (_normalizeInputText, _normalizeInputMedia)_utils.ts or _business-connection.ts etc.*.test.ts