Skip to content

Commit

Permalink
refa(lark): re-generate all the types
Browse files Browse the repository at this point in the history
  • Loading branch information
shigma committed Jan 6, 2025
1 parent fbf1493 commit 9531b30
Show file tree
Hide file tree
Showing 62 changed files with 50,093 additions and 30,641 deletions.
4 changes: 3 additions & 1 deletion adapters/lark/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,9 @@
"devDependencies": {
"@cordisjs/plugin-server": "^0.2.5",
"@satorijs/core": "^4.4.2",
"cordis": "^3.18.1"
"cordis": "^3.18.1",
"cosmokit": "^1.6.3",
"dedent": "^1.5.3"
},
"peerDependencies": {
"@satorijs/core": "^4.4.2"
Expand Down
329 changes: 329 additions & 0 deletions adapters/lark/scripts/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,329 @@
/* eslint-disable no-console */

import { mkdir, readFile, writeFile } from 'node:fs/promises'
import { capitalize } from 'cosmokit'
import pMap from 'p-map'
import dedent from 'dedent'

interface Response<T = any> {
code: number
msg: string
data: T
}

export interface ApiMeta {
Name: string
Project: string
Resource: string
Type: 1
Version: string
}

interface Api {
bizTag: string
chargingMethod: 'none' | 'basic'
detail: string
fullDose: boolean
fullPath: string
id: string
isCharge: boolean
meta: ApiMeta
name: string
orderMark: string
supportAppTypes: string[]
tags: string[]
updateTime: number
url: string
}

interface BizInfo {
desc: string
name: string
}

interface ApiList {
apis: Api[]
bizInfos: BizInfo[]
}

export interface Schema {
name: string
type: string
format: string
description: string
example: string
idTypes: {} // ?
defaultValue: string
ref: string // ?
required: boolean
properties?: Schema[]
items?: Schema
options?: {
name: string
value: string
description: string
}[]
}

interface ApiDetail {
request: {
query: Schema
body?: Schema
path?: Schema
contentType: ''
}
response: {
body: Schema
contentType: string
}
project: string
apiName: string
scopesOfFieldRequired: [] // ?
pagination: boolean
supportFileDownload: boolean
supportFileUpload: boolean
resource: string
apiPath: string
description: string
scopesOfDebugRequired: string[]
errorMappings: {
msg: string
httpCode: number
code: number
}[]
httpMethod: string
version: string
accessTokens: 'tenant'[]
basicRateLimit: {
tier: number
}
}

const refs: Record<string, string> = {}
const projects: Record<string, Project> = {}

async function request<T>(url: string) {
const response = await fetch(url)
const body: Response<T> = await response.json()
if (body.code) throw new Error(`${body.msg}, url: ${url}`)
return body.data
}

async function getDetail(api: Api) {
const path = new URL(`../temp/api/${api.id}.json`, import.meta.url)
try {
return JSON.parse(await readFile(path, 'utf8')) as ApiDetail
} catch {}
const params = new URLSearchParams({
apiName: api.meta.Name,
project: api.meta.Project,
resource: api.meta.Resource,
version: api.meta.Version,
})
const data = await request<ApiDetail>(`https://open.feishu.cn/api_explorer/v1/api_definition?${params}`)
await writeFile(path, JSON.stringify(data))
return data
}

function toHump(name: string) {
return name.replace(/[\_\.](\w)/g, function (all, letter) {
return letter.toUpperCase()
})
}

function formatType(schema: Schema, imports: Set<string>) {
if (!schema.ref) return _formatType(schema, imports)
const name = capitalize(toHump(schema.ref))
imports.add(name)
if (refs[name]) return name
refs[name] = schema.type === 'object' && schema.properties
? `export interface ${name} ${_formatType(schema)}`
: `export type ${name} = ${_formatType(schema)}`
return name
}

function _formatType(schema: Schema, imports = new Set<string>()) {
if (schema.type === 'file') return 'Blob'
if (schema.type === 'int') {
if (schema.options) {
return schema.options.map(v => v.value).join(' | ')
} else {
return 'number'
}
}
if (schema.type === 'float') return 'number'
if (schema.type === 'string') {
if (schema.options) {
return schema.options.map(v => `'${v.value}'`).join(' | ')
} else {
return 'string'
}
}
if (schema.type === 'boolean') return 'boolean'
if (schema.type === 'object') {
if (!schema.properties) return 'unknown'
return `{\n${generateParams(schema.properties, imports)}\n}`
} else if (schema.type === 'list') {
return formatType(schema.items!, imports) + '[]'
}
return 'unknown'
}

function generateParams(properties: Schema[], imports: Set<string>): string {
const getDesc = (v: Schema) => v.description ? ` /** ${v.description.replace(/\n/g, '').trim()} */\n` : ''
return properties.map((schema: Schema) => {
return `${getDesc(schema)} ${schema.name}${schema.required ? '' : '?'}: ${formatType(schema, imports)}`
}).join('\n')
}

function getApiName(detail: ApiDetail) {
let project = detail.project
if (project === 'task' || project === 'drive') {
project = project + detail.version.toUpperCase()
}
if (detail.project === detail.resource) {
return toHump(`${detail.apiName}.${project}`)
} else {
return toHump(`${detail.apiName}.${project}.${detail.resource}`)
}
}

interface Project {
methods: string[]
requests: string[]
responses: string[]
internals: string[]
imports: Set<string>
defines: Record<string, Record<string, string>>
}

async function start() {
await mkdir(new URL('../temp/api', import.meta.url), { recursive: true })
await mkdir(new URL('../src/types', import.meta.url), { recursive: true })
// https://open.feishu.cn/document/server-docs/api-call-guide/server-api-list
const data = await request<ApiList>('https://open.feishu.cn/api/tools/server-side-api/list')
data.apis = data.apis.filter(api => api.meta.Version !== 'old')
await writeFile(new URL('../temp/apis.json', import.meta.url), JSON.stringify(data))
const details = await pMap(data.apis, getDetail, {
concurrency: 10,
})

details.forEach((detail, index) => {
const summary = data.apis[index]
const project = projects[detail.project] ||= {
methods: [],
requests: [],
responses: [],
internals: [],
imports: new Set(),
defines: {},
}

const method = getApiName(detail)
const apiType = capitalize(method)
const args: string[] = []
const extras: string[] = []
let returnType = `${apiType}Response`
// if (api.pagination) console.log(apiName, 'pagination')

for (const property of detail.request.path?.properties || []) {
args.push(`${property.name}: ${formatType(property, project.imports)}`)
}
if (detail.supportFileUpload && detail.request.body?.properties?.length) {
const name = `${apiType}Form`
args.push(`form: ${name}`)
project.requests.push(`export interface ${name} {\n${generateParams(detail.request.body!.properties, project.imports)}\n}`)
extras.push(`multipart: true`)
} else if (detail.request.body?.properties?.length) {
const name = `${apiType}Request`
project.requests.push(`export interface ${name} {\n${generateParams(detail.request.body.properties, project.imports)}\n}`)
args.push(`body: ${name}`)
}
if (detail.request.query?.properties?.length) {
const name = `${apiType}Query`
project.requests.push(`export interface ${name} {\n${generateParams(detail.request.query.properties, project.imports)}\n}`)
args.push(`query?: ${name}`)
}

if (detail.supportFileDownload) {
// detail.response.contentType === ''
returnType = 'ArrayBuffer'
extras.push(`type: 'binary'`)
} else {
const keys = (detail.response.body?.properties || []).map(v => v.name)
if (!keys.includes('code') || !keys.includes('msg')) {
console.log(`unknown response body keys: ${keys}, see https://open.feishu.cn${summary.fullPath}}`)
return
} else if (keys.length === 2) {
returnType = 'void'
} else if (keys.length === 3 && keys.includes('data')) {
const data = detail.response.body.properties!.find(v => v.name === 'data')!
if (!data.properties?.length) {
returnType = 'void'
} else {
project.responses.push(`export interface ${returnType} {\n${generateParams(data.properties, project.imports)}\n}`)
}
} else {
project.responses.push(`export interface ${returnType} extends BaseResponse {\n${generateParams(detail.response.body.properties!, project.imports)}\n}`)
extras.push(`type: 'raw-json'`)
}
}

project.methods.push(dedent`
/**
* ${summary.name}
* @see https://open.feishu.cn${summary.fullPath}
*/
${method}(${args.join(', ')}): Promise<${returnType}>
`)

const path = detail.apiPath.replace(/:([0-9a-zA-Z_]+)/g, '{$1}')
project.defines[path] ||= {}
project.defines[path][detail.httpMethod] = extras.length
? `{ name: '${method}', ${extras.join(', ')} }`
: `'${method}'`
})

await Promise.all(Object.entries(projects).map(async ([name, project]) => {
const path = new URL(`../src/types/${name}.ts`, import.meta.url)
const defines = Object.entries(project.defines).map(([path, methods]) => {
const content = Object.entries(methods).map(([method, value]) => {
return ` ${method}: ${value},`
}).join('\n')
return `'${path}': {\n${content}\n },`
}).join('\n ')
const imports = [`import { Internal } from '../internal'`]
if (project.imports.size) {
imports.push(`import { ${[...project.imports].sort().join(', ')} } from '.'`)
}
await writeFile(path, [
imports.join('\n'),
dedent`
declare module '../internal' {
interface Internal {
__METHODS__
}
}
`.replace('__METHODS__', project.methods.join('\n').split('\n').join('\n ')),
...project.requests,
...project.responses,
dedent`
Internal.define({
__DEFINES__
})
`.replace('__DEFINES__', defines),
].join('\n\n') + '\n')
}))

await writeFile(new URL('../src/types/index.ts', import.meta.url), [
Object.entries(projects)
.sort(([a], [b]) => a.localeCompare(b))
.map(([name]) => `export * from './${name}'`)
.join('\n'),
...Object.entries(refs)
.sort(([a], [b]) => a.localeCompare(b))
.map(([_, value]) => value),
].join('\n\n') + '\n')
}

start()
22 changes: 19 additions & 3 deletions adapters/lark/src/bot.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,19 @@
import { Bot, Context, h, HTTP, Schema, Time, Universal } from '@satorijs/core'

import { CreateImFileForm } from './types'
import { HttpServer } from './http'
import { LarkMessageEncoder } from './message'
import { Internal } from './types'
import { Internal } from './internal'
import * as Utils from './utils'

const fileTypeMap: Record<Exclude<CreateImFileForm['file_type'], 'stream'>, string[]> = {
opus: ['audio/opus'],
mp4: ['video/mp4'],
pdf: ['application/pdf'],
doc: ['application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'],
xls: ['application/vnd.ms-excel', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'],
ppt: ['application/vnd.ms-powerpoint', 'application/vnd.openxmlformats-officedocument.presentationml.presentation'],
}

export class LarkBot<C extends Context = Context> extends Bot<C, LarkBot.Config> {
static inject = ['server', 'http']
static MessageEncoder = LarkMessageEncoder
Expand Down Expand Up @@ -145,9 +154,16 @@ export class LarkBot<C extends Context = Context> extends Bot<C, LarkBot.Config>

async createUpload(...uploads: Universal.Upload[]): Promise<string[]> {
return await Promise.all(uploads.map(async (upload) => {
let type: CreateImFileForm['file_type'] = 'stream'
for (const [key, value] of Object.entries(fileTypeMap)) {
if (value.includes(upload.type)) {
type = key as CreateImFileForm['file_type']
break
}
}
const response = await this.internal.createImFile({
file_name: upload.filename,
file_type: upload.type,
file_type: type,
file: new Blob([upload.data]),
})
return this.getInternalUrl(`/im/v1/files/${response.file_key}`)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
/* eslint-disable max-len */
// https://open.larksuite.com/document/server-docs/im-v1/message-content-description/create_json

declare global {
Expand Down
4 changes: 1 addition & 3 deletions adapters/lark/src/http.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
import { Adapter, Context, Logger, Schema } from '@satorijs/core'
import {} from '@cordisjs/plugin-server'

import { LarkBot } from './bot'
import { EventPayload } from './types'
import { adaptSession, Cipher } from './utils'
import { adaptSession, Cipher, EventPayload } from './utils'

export class HttpServer<C extends Context = Context> extends Adapter<C, LarkBot<C>> {
static inject = ['server']
Expand Down
Loading

0 comments on commit 9531b30

Please sign in to comment.