Writing High-Level Methods
Guide for adding/modifying methods in packages/core/src/highlevel/methods/.
Also read the using-mtcute skill for more information.
TL Schema Lookup
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.
Function Signature Convention
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:
- First arg is always
client: ITelegramClient(stripped by codegen for the class method) - Primary identifiers (chatId, userId, messageIds) are positional
- Optional/secondary params go in a single trailing
params?object - When 3+ required params of similar importance exist, consolidate into a single
paramsobject (seeeditAdminRights) - All exports need explicit return types (
isolatedDeclarations: true) - Generators use
AsyncIterableIterator<T>return type
Codegen Annotations
Files are processed by packages/core/scripts/generate-client.cjs. Annotations are // @annotation comments on the line before the statement.
// @copy
Copies the import/statement into generated client.ts. Used ONLY in _imports.ts for shared imports.
// @copy
import { tl } from '../../tl/index.js'
// @exported
Re-exports a type from the generated methods.ts. Use for params interfaces and offset types.
// @exported
export interface DeleteMessagesParams { ... }
// @available=user|bot|both
Controls the **Available**: JSDoc annotation. If absent, auto-detected from which TL methods are called.
Add it explicitly when auto-detection is wrong or has nothing to go on (the schema
carries no availability data for some namespaces, e.g. ephemeral.*). Prefer this
over documenting the constraint in prose.
// @available=user
export async function getWelcomeMessages(...) { ... }
// @alias=name1,name2
Creates additional class method aliases.
// @alias=deleteSupergroup
export async function deleteChannel(...) { ... }
// @skip
Excludes 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(...) { ... }
After adding/modifying methods:
node packages/core/scripts/generate-client.cjs
Peer Resolution
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.
Chat vs Channel dispatch
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').
Batched queries
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.
Update Handling
Methods returning tl.TypeUpdates
const 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
Finding messages in update responses
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)
Methods returning messages.affectedMessages / messages.affectedHistory
These 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 pattern
Methods 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)
Text / Entity Normalization
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.
Handling 2FA passwords
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,
)
Sending Messages
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 },
)
Chain ID for message ordering
import { _getPeerChainId } from '../misc/chain-id.js'
const chainId = _getPeerChainId(client, peer, 'send')
await client.call(request, { chainId })
File / Media Normalization
// InputFileLike → tl.TypeInputFile (triggers upload if needed)
const file = await client._normalizeInputFile(input, params)
// InputMediaLike → tl.TypeInputMedia
const media = await client._normalizeInputMedia(media, params)
Pagination
Offset-based with ArrayPaginated
import { 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.
Iterator wrapper over paginated getter
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 count
import { makeArrayWithTotal } from '../../utils/index.js'
return makeArrayWithTotal(items, total)
Return Types
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))
Error Handling
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')
Common Utilities
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()— randomLongforrandomIdfieldsnormalizeDate(d)—Date | number | undefined→ UNIX secondsnormalizeMessageId(m)—number | Message | undefined→number | undefinedLong.ZERO— forhashfields (fromlongpackage)
TL Flag Mapping
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 ?? {}
File Organization
- Public methods: named after the action (
send-text.ts,get-history.ts,delete-messages.ts) - Internal helpers: prefixed
_, annotated@internal @noemit(_processCommonSendParameters,_findMessageInUpdate) - Shared params interfaces: exported with
// @exportedfrom the relevant file - Normalizers: prefixed
_normalize*(_normalizeInputText,_normalizeInputMedia) - Utility files:
_utils.tsor_business-connection.tsetc. - Tests: colocated as
*.test.ts