docs: generate user/bot availability automagically

closes MTQ-85
This commit is contained in:
alina 🌸 2023-10-05 04:00:58 +03:00
parent ec55cb37f7
commit ff75d40e78
Signed by: teidesu
SSH key fingerprint: SHA256:uNeCpw6aTSU4aIObXLvHfLkDa82HWH9EiOj9AXOIRpI
7 changed files with 695 additions and 32 deletions

View file

@ -4,6 +4,15 @@ const fs = require('fs')
const prettier = require('prettier') const prettier = require('prettier')
const updates = require('./generate-updates') const updates = require('./generate-updates')
const schema = require('../../tl/api-schema.json')
function findMethodAvailability(method) {
const entry = schema.e.find((it) => it.kind === 'method' && it.name === method)
if (!entry) return null
return entry.available ?? null
}
const targetDir = path.join(__dirname, '../src') const targetDir = path.join(__dirname, '../src')
async function* getFiles(dir) { async function* getFiles(dir) {
@ -29,6 +38,145 @@ ${text}`,
process.exit(0) process.exit(0)
} }
function visitRecursively(ast, check, callback) {
const visit = (node) => {
if (!ts.isNode(node)) return
// recursively continue visiting
for (const [key, value] of Object.entries(node)) {
if (!value || typeof value !== 'object' || key === 'parent') {
continue
}
if (Array.isArray(value)) {
value.forEach(visit)
} else {
visit(value)
}
}
if (check(node)) {
callback(node)
}
}
visit(ast)
}
function findRawApiUsages(ast, fileName) {
// find `this.call({ _: '...', ...})
const usages = []
visitRecursively(
ast,
(node) => node.kind === ts.SyntaxKind.CallExpression,
(call) => {
if (call.expression.kind !== ts.SyntaxKind.PropertyAccessExpression) return
const prop = call.expression
if (prop.name.escapedText === 'call' && prop.expression.kind === ts.SyntaxKind.ThisKeyword) {
usages.push(call)
}
},
)
const methodUsages = []
for (const call of usages) {
const arg = call.arguments[0]
if (!arg || arg.kind !== ts.SyntaxKind.ObjectLiteralExpression) {
throwError(
call,
fileName,
'First argument to this.call() must be an object literal. Please use @available directive manually',
)
}
const method = arg.properties.find((it) => it.name.escapedText === '_')
if (!method || method.kind !== ts.SyntaxKind.PropertyAssignment) {
throwError(call, fileName, 'First argument to this.call() must have a _ property')
}
const init = method.initializer
if (init.kind === ts.SyntaxKind.StringLiteral) {
methodUsages.push(init.text)
} else if (init.kind === ts.SyntaxKind.ConditionalExpression) {
const whenTrue = init.whenTrue
const whenFalse = init.whenFalse
if (whenTrue.kind !== ts.SyntaxKind.StringLiteral || whenFalse.kind !== ts.SyntaxKind.StringLiteral) {
throwError(
call,
fileName,
'Too complex, failed to extract method name, please use @available directive manually',
)
}
methodUsages.push(whenTrue.text, whenFalse.text)
} else {
throwError(
call,
fileName,
'Too complex, failed to extract method name, please use @available directive manually',
)
}
}
return methodUsages
}
function findDependencies(ast) {
const deps = new Set()
visitRecursively(
ast,
(node) => node.kind === ts.SyntaxKind.CallExpression,
(call) => {
if (call.expression.kind !== ts.SyntaxKind.PropertyAccessExpression) return
const prop = call.expression
if (
prop.name.escapedText !== 'call' &&
prop.name.escapedText !== '_emitError' &&
prop.name.escapedText !== '_cachePeersFrom' &&
prop.name.escapedText !== 'importSession' &&
prop.name.escapedText !== 'emit' &&
prop.expression.kind === ts.SyntaxKind.ThisKeyword
) {
deps.add(prop.name.escapedText)
}
},
)
return [...deps]
}
function determineCommonAvailability(methods, resolver = (v) => v) {
let common = 'both'
for (const method of methods) {
const available = resolver(method)
if (available === null) {
console.log('availability null for ' + method)
return null
}
if (common === 'both') {
common = available
} else if (available !== 'both' && common !== available) {
return null
}
}
return common
}
async function addSingleMethod(state, fileName) { async function addSingleMethod(state, fileName) {
const fileFullText = await fs.promises.readFile(fileName, 'utf-8') const fileFullText = await fs.promises.readFile(fileName, 'utf-8')
const program = ts.createSourceFile(path.basename(fileName), fileFullText, ts.ScriptTarget.ES2018, true) const program = ts.createSourceFile(path.basename(fileName), fileFullText, ts.ScriptTarget.ES2018, true)
@ -117,6 +265,21 @@ async function addSingleMethod(state, fileName) {
return aliases.split(',') return aliases.split(',')
})() })()
const available = (function () {
const flag = checkForFlag(stmt, '@available')
if (!flag) return null
const [, available] = flag.split('=')
if (!available || !available.length) return null
if (available !== 'user' && available !== 'bot' && available !== 'both') {
throwError(stmt, fileName, `Invalid value for @available flag: ${available}`)
}
return available
})()
const rawApiMethods = available === null && findRawApiUsages(stmt, fileName)
const dependencies = findDependencies(stmt).filter((it) => it !== name)
if (!isExported && !isPrivate) { if (!isExported && !isPrivate) {
throwError(stmt, fileName, 'Public methods MUST be exported.') throwError(stmt, fileName, 'Public methods MUST be exported.')
@ -178,6 +341,9 @@ async function addSingleMethod(state, fileName) {
func: stmt, func: stmt,
comment: getLeadingComments(stmt), comment: getLeadingComments(stmt),
aliases, aliases,
available,
rawApiMethods,
dependencies,
overload: isOverload, overload: isOverload,
hasOverloads: hasOverloads[name] && !isOverload, hasOverloads: hasOverloads[name] && !isOverload,
}) })
@ -270,7 +436,7 @@ async function main() {
output.write( output.write(
'/* eslint-disable @typescript-eslint/no-unsafe-declaration-merging, @typescript-eslint/unified-signatures */\n' + '/* eslint-disable @typescript-eslint/no-unsafe-declaration-merging, @typescript-eslint/unified-signatures */\n' +
'/* THIS FILE WAS AUTO-GENERATED */\n' + '/* THIS FILE WAS AUTO-GENERATED */\n' +
"import { BaseTelegramClient, BaseTelegramClientOptions, tl } from '@mtcute/core'\n", "import { BaseTelegramClient, BaseTelegramClientOptions, tl, Long } from '@mtcute/core'\n",
) )
Object.entries(state.imports).forEach(([module, items]) => { Object.entries(state.imports).forEach(([module, items]) => {
items = [...items] items = [...items]
@ -323,7 +489,42 @@ on(name: '${type.typeName}', handler: ((upd: ${type.updateType}) => void)): this
aliases, aliases,
overload, overload,
hasOverloads, hasOverloads,
available,
rawApiMethods,
dependencies,
}) => { }) => {
if (!available && !overload) {
// no @available directive
// try to determine it automatically
const checkDepsAvailability = (deps) => {
return determineCommonAvailability(deps, (name) => {
const method = state.methods.list.find((it) => it.name === name && !it.overload)
if (!method) {
throwError(
func,
origName,
`Cannot determine availability of ${name}, is it a client method? Please use @available directive manually`,
)
}
if (method.available === null) {
return determineCommonAvailability([
determineCommonAvailability(method.rawApiMethods, findMethodAvailability),
checkDepsAvailability(method.dependencies),
])
}
return method.available
})
}
available = determineCommonAvailability([
determineCommonAvailability(rawApiMethods, findMethodAvailability),
checkDepsAvailability(dependencies),
])
}
// create method that calls that function and passes `this` // create method that calls that function and passes `this`
// first let's determine the signature // first let's determine the signature
const returnType = func.type ? ': ' + func.type.getText() : '' const returnType = func.type ? ': ' + func.type.getText() : ''
@ -389,18 +590,27 @@ on(name: '${type.typeName}', handler: ((upd: ${type.updateType}) => void)): this
// remove @internal mark and set default values for parameters // remove @internal mark and set default values for parameters
comment = comment comment = comment
.replace(/^\s*\/\/+\s*@alias.*$/m, '') .replace(/^\s*\/\/+\s*@(alias|available).*$/m, '')
.replace(/(\n^|\/\*)\s*\*\s*@internal.*/m, '') .replace(/(\n^|\/\*)\s*\*\s*@internal.*/m, '')
.replace(/((?:\n^|\/\*)\s*\*\s*@param )([^\s]+?)($|\s+)/gm, (_, pref, arg, post) => { .replace(/((?:\n^|\/\*)\s*\*\s*@param )([^\s]+?)($|\s+)/gm, (_, pref, arg, post) => {
const param = rawParams.find((it) => it.name.escapedText === arg) const param = rawParams.find((it) => it.name.escapedText === arg)
if (!param) return _ if (!param) return _
if (!param._savedDefault) return _ if (!param._savedDefault) return _
if (post) { return `${pref}[${arg}=${param._savedDefault.trim()}]${post}`
return `${pref}${arg}${post}(default: \`${param._savedDefault.trim()}\`) ` })
// insert "some text" at the end of comment before jsdoc
.replace(/(?<=\/\*.*)(?=\n\s*\*\s*(?:@[a-z]+|\/))/s, () => {
switch (available) {
case 'user':
return '\n * **Available**: 👤 users only\n *'
case 'bot':
return '\n * **Available**: 🤖 bots only\n *'
case 'both':
return '\n * **Available**: ✅ both users and bots\n *'
} }
return `${pref}${arg}\n* (default: \`${param._savedDefault.trim()}\`)` return ''
}) })
for (const name of [origName, ...aliases]) { for (const name of [origName, ...aliases]) {

File diff suppressed because it is too large Load diff

View file

@ -5,6 +5,7 @@ import { TelegramClient } from '../../client'
import { MaybeDynamic, SentCode, TermsOfService, User } from '../../types' import { MaybeDynamic, SentCode, TermsOfService, User } from '../../types'
import { normalizePhoneNumber, resolveMaybeDynamic } from '../../utils/misc-utils' import { normalizePhoneNumber, resolveMaybeDynamic } from '../../utils/misc-utils'
// @available=both
/** /**
* Start the client in an interactive and declarative manner, * Start the client in an interactive and declarative manner,
* by providing callbacks for authorization details. * by providing callbacks for authorization details.

View file

@ -1,7 +1,8 @@
import { tl } from '@mtcute/core' import { Long } from '@mtcute/core'
import { TelegramClient } from '../../client' import { TelegramClient } from '../../client'
// @available=bot
/** /**
* Send an answer to a callback query. * Send an answer to a callback query.
* *
@ -11,7 +12,7 @@ import { TelegramClient } from '../../client'
*/ */
export async function answerCallbackQuery( export async function answerCallbackQuery(
this: TelegramClient, this: TelegramClient,
queryId: tl.Long, queryId: Long,
params?: { params?: {
/** /**
* Maximum amount of time in seconds for which * Maximum amount of time in seconds for which

View file

@ -31,6 +31,7 @@ const REQUESTS_PER_CONNECTION = 3
const MAX_PART_COUNT = 4000 // 512 kb * 4000 = 2000 MiB const MAX_PART_COUNT = 4000 // 512 kb * 4000 = 2000 MiB
const MAX_PART_COUNT_PREMIUM = 8000 // 512 kb * 8000 = 4000 MiB const MAX_PART_COUNT_PREMIUM = 8000 // 512 kb * 8000 = 4000 MiB
// @available=both
/** /**
* Upload a file to Telegram servers, without actually * Upload a file to Telegram servers, without actually
* sending a message anywhere. Useful when an `InputFile` is required. * sending a message anywhere. Useful when an `InputFile` is required.

View file

@ -41,6 +41,7 @@ export async function getMessages(
fromReply?: boolean, fromReply?: boolean,
): Promise<(Message | null)[]> ): Promise<(Message | null)[]>
// @available=both
/** @internal */ /** @internal */
export async function getMessages( export async function getMessages(
this: TelegramClient, this: TelegramClient,

View file

@ -5,6 +5,7 @@ import { TelegramClient } from '../../client'
import { InputPeerLike, MtPeerNotFoundError } from '../../types' import { InputPeerLike, MtPeerNotFoundError } from '../../types'
import { normalizeToInputPeer } from '../../utils/peer-utils' import { normalizeToInputPeer } from '../../utils/peer-utils'
// @available=both
/** /**
* Get the `InputPeer` of a known peer id. * Get the `InputPeer` of a known peer id.
* Useful when an `InputPeer` is needed. * Useful when an `InputPeer` is needed.