@satorijs/adapter-lark 3.1.4 → 3.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/bot.d.ts +1 -0
- package/lib/http.d.ts +2 -0
- package/lib/index.js +20 -18
- package/lib/index.js.map +2 -3
- package/lib/types/internal.d.ts +4 -3
- package/package.json +8 -4
- package/src/bot.ts +155 -0
- package/src/http.ts +157 -0
- package/src/index.ts +15 -0
- package/src/message.ts +185 -0
- package/src/types/.eslintrc.yml +2 -0
- package/src/types/auth.ts +54 -0
- package/src/types/event.ts +22 -0
- package/src/types/guild.ts +64 -0
- package/src/types/index.ts +41 -0
- package/src/types/internal.ts +62 -0
- package/src/types/message/asset.ts +50 -0
- package/src/types/message/content.ts +102 -0
- package/src/types/message/index.ts +233 -0
- package/src/types/user.ts +91 -0
- package/src/types/utils.ts +7 -0
- package/src/utils.ts +199 -0
package/src/http.ts
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import internal from 'stream'
|
|
2
|
+
import { Adapter, Context, Logger, Schema } from '@satorijs/satori'
|
|
3
|
+
import {} from '@satorijs/router'
|
|
4
|
+
|
|
5
|
+
import { FeishuBot } from './bot'
|
|
6
|
+
import { AllEvents } from './types'
|
|
7
|
+
import { adaptSession, Cipher } from './utils'
|
|
8
|
+
|
|
9
|
+
export class HttpServer<C extends Context = Context> extends Adapter<C, FeishuBot<C>> {
|
|
10
|
+
static inject = ['router']
|
|
11
|
+
|
|
12
|
+
private logger: Logger
|
|
13
|
+
private ciphers: Record<string, Cipher> = {}
|
|
14
|
+
|
|
15
|
+
constructor(ctx: C, bot: FeishuBot<C>) {
|
|
16
|
+
super(ctx)
|
|
17
|
+
this.logger = ctx.logger('lark')
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
fork(ctx: C, bot: FeishuBot<C>) {
|
|
21
|
+
super.fork(ctx, bot)
|
|
22
|
+
|
|
23
|
+
this._refreshCipher()
|
|
24
|
+
return bot.initialize()
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async connect(bot: FeishuBot) {
|
|
28
|
+
const { path } = bot.config
|
|
29
|
+
bot.ctx.router.post(path, (ctx) => {
|
|
30
|
+
this._refreshCipher()
|
|
31
|
+
|
|
32
|
+
// compare signature if encryptKey is set
|
|
33
|
+
// But not every message contains signature
|
|
34
|
+
// https://open.larksuite.com/document/ukTMukTMukTM/uYDNxYjL2QTM24iN0EjN/event-subscription-configure-/encrypt-key-encryption-configuration-case#d41e8916
|
|
35
|
+
const signature = ctx.get('X-Lark-Signature')
|
|
36
|
+
const enabledSignatureVerify = this.bots.filter((bot) => bot.config.verifySignature)
|
|
37
|
+
if (signature && enabledSignatureVerify.length) {
|
|
38
|
+
const result = enabledSignatureVerify.some((bot) => {
|
|
39
|
+
const timestamp = ctx.get('X-Lark-Request-Timestamp')
|
|
40
|
+
const nonce = ctx.get('X-Lark-Request-Nonce')
|
|
41
|
+
const body = ctx.request.rawBody
|
|
42
|
+
const actualSignature = this.ciphers[bot.config.appId]?.calculateSignature(timestamp, nonce, body)
|
|
43
|
+
if (actualSignature === signature) return true
|
|
44
|
+
else return false
|
|
45
|
+
})
|
|
46
|
+
if (!result) return (ctx.status = 403)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// try to decrypt message first if encryptKey is set
|
|
50
|
+
const body = this._tryDecryptBody(ctx.request.body)
|
|
51
|
+
// respond challenge message
|
|
52
|
+
// https://open.larksuite.com/document/ukTMukTMukTM/uYDNxYjL2QTM24iN0EjN/event-subscription-configure-/request-url-configuration-case
|
|
53
|
+
if (body?.type === 'url_verification' && body?.challenge && typeof body.challenge === 'string') {
|
|
54
|
+
ctx.response.body = { challenge: body.challenge }
|
|
55
|
+
return
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// compare verification token
|
|
59
|
+
const enabledVerifyTokenVerify = this.bots.filter((bot) => bot.config.verifyToken && bot.config.verificationToken)
|
|
60
|
+
if (enabledVerifyTokenVerify.length) {
|
|
61
|
+
const token = ctx.request.body?.token
|
|
62
|
+
// only compare token if token exists
|
|
63
|
+
if (token) {
|
|
64
|
+
const result = enabledVerifyTokenVerify.some((bot) => {
|
|
65
|
+
if (token === bot.config.verificationToken) return true
|
|
66
|
+
else return false
|
|
67
|
+
})
|
|
68
|
+
if (!result) return (ctx.status = 403)
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// dispatch message
|
|
73
|
+
bot.logger.debug('received decryped event: %o', body)
|
|
74
|
+
this.dispatchSession(body)
|
|
75
|
+
|
|
76
|
+
// Lark requires 200 OK response to make sure event is received
|
|
77
|
+
return ctx.status = 200
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
bot.ctx.router.get(path + '/assets/:type/:message_id/:key', async (ctx) => {
|
|
81
|
+
const type = ctx.params.type === 'image' ? 'image' : 'file'
|
|
82
|
+
const key = ctx.params.key
|
|
83
|
+
const messageId = ctx.params.message_id
|
|
84
|
+
const selfId = ctx.request.query.self_id
|
|
85
|
+
const bot = this.bots.find((bot) => bot.selfId === selfId)
|
|
86
|
+
if (!bot) return ctx.status = 404
|
|
87
|
+
|
|
88
|
+
const resp = await bot.http.axios<internal.Readable>(`/im/v1/messages/${messageId}/resources/${key}`, {
|
|
89
|
+
method: 'GET',
|
|
90
|
+
params: { type },
|
|
91
|
+
responseType: 'stream',
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
ctx.status = 200
|
|
95
|
+
ctx.response.headers['Content-Type'] = resp.headers['content-type']
|
|
96
|
+
ctx.response.body = resp.data
|
|
97
|
+
})
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
dispatchSession(body: AllEvents): void {
|
|
101
|
+
const { header } = body
|
|
102
|
+
if (!header) return
|
|
103
|
+
const { app_id, event_type } = header
|
|
104
|
+
body.type = event_type // add type to body to ease typescript type narrowing
|
|
105
|
+
const bot = this.bots.find((bot) => bot.selfId === app_id)
|
|
106
|
+
const session = adaptSession(bot, body)
|
|
107
|
+
bot.dispatch(session)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
private _tryDecryptBody(body: any): any {
|
|
111
|
+
this._refreshCipher()
|
|
112
|
+
// try to decrypt message if encryptKey is set
|
|
113
|
+
// https://open.larksuite.com/document/ukTMukTMukTM/uYDNxYjL2QTM24iN0EjN/event-subscription-configure-/encrypt-key-encryption-configuration-case
|
|
114
|
+
const ciphers = Object.values(this.ciphers)
|
|
115
|
+
if (ciphers.length && typeof body.encrypt === 'string') {
|
|
116
|
+
for (const cipher of ciphers) {
|
|
117
|
+
try {
|
|
118
|
+
return JSON.parse(cipher.decrypt(body.encrypt))
|
|
119
|
+
} catch {}
|
|
120
|
+
}
|
|
121
|
+
this.logger.warn('failed to decrypt message: %o', body)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (typeof body.encrypt === 'string' && !ciphers.length) {
|
|
125
|
+
this.logger.warn('encryptKey is not set, but received encrypted message: %o', body)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return body
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
private _refreshCipher(): void {
|
|
132
|
+
const ciphers = Object.keys(this.ciphers)
|
|
133
|
+
const bots = this.bots.map((bot) => bot.config.appId)
|
|
134
|
+
if (bots.length === ciphers.length && bots.every((bot) => ciphers.includes(bot))) return
|
|
135
|
+
|
|
136
|
+
this.ciphers = {}
|
|
137
|
+
for (const bot of this.bots) {
|
|
138
|
+
this.ciphers[bot.config.appId] = new Cipher(bot.config.encryptKey)
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export namespace HttpServer {
|
|
144
|
+
export interface Config {
|
|
145
|
+
selfUrl?: string
|
|
146
|
+
path?: string
|
|
147
|
+
verifyToken?: boolean
|
|
148
|
+
verifySignature?: boolean
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export const createConfig = (path: string): Schema<Config> => Schema.object({
|
|
152
|
+
path: Schema.string().role('url').description('要连接的服务器地址。').default(path),
|
|
153
|
+
selfUrl: Schema.string().role('link').description('服务器暴露在公网的地址。缺省时将使用全局配置。'),
|
|
154
|
+
verifyToken: Schema.boolean().description('是否验证令牌。'),
|
|
155
|
+
verifySignature: Schema.boolean().description('是否验证签名。'),
|
|
156
|
+
}).description('服务端设置')
|
|
157
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { FeishuBot } from './bot'
|
|
2
|
+
import * as Lark from './types'
|
|
3
|
+
|
|
4
|
+
export * from './bot'
|
|
5
|
+
|
|
6
|
+
export { Lark, Lark as Feishu }
|
|
7
|
+
|
|
8
|
+
export default FeishuBot
|
|
9
|
+
|
|
10
|
+
declare module '@satorijs/core' {
|
|
11
|
+
interface Session {
|
|
12
|
+
feishu: Lark.Internal
|
|
13
|
+
lark: Lark.Internal
|
|
14
|
+
}
|
|
15
|
+
}
|
package/src/message.ts
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { createReadStream } from 'fs'
|
|
2
|
+
import internal from 'stream'
|
|
3
|
+
|
|
4
|
+
import { Context, h, MessageEncoder, Quester } from '@satorijs/satori'
|
|
5
|
+
import FormData from 'form-data'
|
|
6
|
+
|
|
7
|
+
import { LarkBot } from './bot'
|
|
8
|
+
import { BaseResponse, Message, MessageContent, MessageType } from './types'
|
|
9
|
+
import { extractIdType } from './utils'
|
|
10
|
+
|
|
11
|
+
export interface Addition {
|
|
12
|
+
file: MessageContent.MediaContents
|
|
13
|
+
type: MessageType
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export class LarkMessageEncoder<C extends Context = Context> extends MessageEncoder<C, LarkBot<C>> {
|
|
17
|
+
private quote: string | undefined
|
|
18
|
+
private content = ''
|
|
19
|
+
private addition: Addition
|
|
20
|
+
// TODO: currently not used, would be supported in the future
|
|
21
|
+
private richText: MessageContent.RichText[string]
|
|
22
|
+
|
|
23
|
+
async post(data?: any) {
|
|
24
|
+
try {
|
|
25
|
+
let resp: BaseResponse & { data: Message }
|
|
26
|
+
if (this.quote) {
|
|
27
|
+
resp = await this.bot.internal?.replyMessage(this.quote, data)
|
|
28
|
+
} else {
|
|
29
|
+
data.receive_id = this.channelId
|
|
30
|
+
resp = await this.bot.internal?.sendMessage(extractIdType(this.channelId), data)
|
|
31
|
+
}
|
|
32
|
+
const session = this.bot.session()
|
|
33
|
+
session.messageId = resp.data.message_id
|
|
34
|
+
session.timestamp = Number(resp.data.create_time) * 1000
|
|
35
|
+
session.userId = resp.data.sender.id
|
|
36
|
+
session.app.emit(session, 'send', session)
|
|
37
|
+
this.results.push(session.event.message)
|
|
38
|
+
} catch (e) {
|
|
39
|
+
// try to extract error message from Lark API
|
|
40
|
+
if (Quester.isAxiosError(e)) {
|
|
41
|
+
if (e.response?.data?.code) {
|
|
42
|
+
const generalErrorMsg = `Check error code at https://open.larksuite.com/document/server-docs/getting-started/server-error-codes`
|
|
43
|
+
e.message += ` (Lark error code ${e.response.data.code}: ${e.response.data.msg ?? generalErrorMsg})`
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
this.errors.push(e)
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async flush() {
|
|
51
|
+
if (this.content === '' && !this.addition && !this.richText) return
|
|
52
|
+
|
|
53
|
+
let message: MessageContent.Contents
|
|
54
|
+
if (this.addition) {
|
|
55
|
+
message = {
|
|
56
|
+
...message,
|
|
57
|
+
...this.addition.file,
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
if (this.richText) {
|
|
61
|
+
message = { zh_cn: this.richText }
|
|
62
|
+
}
|
|
63
|
+
if (this.content) {
|
|
64
|
+
message = { text: this.content }
|
|
65
|
+
}
|
|
66
|
+
await this.post({
|
|
67
|
+
msg_type: this.richText ? 'post' : this.addition ? this.addition.type : 'text',
|
|
68
|
+
content: JSON.stringify(message),
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
// reset cached content
|
|
72
|
+
this.quote = undefined
|
|
73
|
+
this.content = ''
|
|
74
|
+
this.addition = undefined
|
|
75
|
+
this.richText = undefined
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async sendFile(type: 'image' | 'video' | 'audio' | 'file', url: string): Promise<Addition> {
|
|
79
|
+
const payload = new FormData()
|
|
80
|
+
|
|
81
|
+
const assetKey = type === 'image' ? 'image' : 'file'
|
|
82
|
+
const [schema, file] = url.split('://')
|
|
83
|
+
const filename = schema === 'base64' ? 'unknown' : new URL(url).pathname.split('/').pop()
|
|
84
|
+
if (schema === 'file') {
|
|
85
|
+
payload.append(assetKey, createReadStream(file))
|
|
86
|
+
} else if (schema === 'base64') {
|
|
87
|
+
payload.append(assetKey, Buffer.from(file, 'base64'))
|
|
88
|
+
} else {
|
|
89
|
+
const resp = await this.bot.assetsQuester.get<internal.Readable>(url, { responseType: 'stream' })
|
|
90
|
+
payload.append(assetKey, resp)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (type === 'image') {
|
|
94
|
+
payload.append('image_type', 'message')
|
|
95
|
+
const { data } = await this.bot.internal.uploadImage(payload)
|
|
96
|
+
return {
|
|
97
|
+
type: 'image',
|
|
98
|
+
file: {
|
|
99
|
+
image_key: data.image_key,
|
|
100
|
+
},
|
|
101
|
+
}
|
|
102
|
+
} else {
|
|
103
|
+
let msgType: MessageType = 'file'
|
|
104
|
+
if (type === 'audio') {
|
|
105
|
+
// FIXME: only support opus
|
|
106
|
+
payload.append('file_type', 'opus')
|
|
107
|
+
msgType = 'audio'
|
|
108
|
+
} else if (type === 'video') {
|
|
109
|
+
// FIXME: only support mp4
|
|
110
|
+
payload.append('file_type', 'mp4')
|
|
111
|
+
msgType = 'media'
|
|
112
|
+
} else {
|
|
113
|
+
const ext = filename.split('.').pop()
|
|
114
|
+
if (['xls', 'ppt', 'pdf'].includes(ext)) {
|
|
115
|
+
payload.append('file_type', ext)
|
|
116
|
+
} else {
|
|
117
|
+
payload.append('file_type', 'stream')
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
payload.append('file_name', filename)
|
|
121
|
+
const { data } = await this.bot.internal.uploadFile(payload)
|
|
122
|
+
return {
|
|
123
|
+
type: msgType,
|
|
124
|
+
file: {
|
|
125
|
+
file_key: data.file_key,
|
|
126
|
+
},
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async visit(element: h) {
|
|
132
|
+
const { type, attrs, children } = element
|
|
133
|
+
|
|
134
|
+
switch (type) {
|
|
135
|
+
case 'text':
|
|
136
|
+
this.content += attrs.content
|
|
137
|
+
break
|
|
138
|
+
case 'at': {
|
|
139
|
+
if (attrs.type === 'all') {
|
|
140
|
+
this.content += `<at user_id="all">${attrs.name ?? '所有人'}</at>`
|
|
141
|
+
} else {
|
|
142
|
+
this.content += `<at user_id="${attrs.id}">${attrs.name}</at>`
|
|
143
|
+
}
|
|
144
|
+
break
|
|
145
|
+
}
|
|
146
|
+
case 'a':
|
|
147
|
+
await this.render(children)
|
|
148
|
+
if (attrs.href) this.content += ` (${attrs.href})`
|
|
149
|
+
break
|
|
150
|
+
case 'p':
|
|
151
|
+
if (!this.content.endsWith('\n')) this.content += '\n'
|
|
152
|
+
await this.render(children)
|
|
153
|
+
if (!this.content.endsWith('\n')) this.content += '\n'
|
|
154
|
+
break
|
|
155
|
+
case 'br':
|
|
156
|
+
this.content += '\n'
|
|
157
|
+
break
|
|
158
|
+
case 'sharp':
|
|
159
|
+
// platform does not support sharp
|
|
160
|
+
break
|
|
161
|
+
case 'quote':
|
|
162
|
+
await this.flush()
|
|
163
|
+
this.quote = attrs.id
|
|
164
|
+
break
|
|
165
|
+
case 'image':
|
|
166
|
+
case 'video':
|
|
167
|
+
case 'audio':
|
|
168
|
+
case 'file':
|
|
169
|
+
if (attrs.url) {
|
|
170
|
+
await this.flush()
|
|
171
|
+
this.addition = await this.sendFile(type, attrs.url)
|
|
172
|
+
}
|
|
173
|
+
break
|
|
174
|
+
case 'figure': // FIXME: treat as message element for now
|
|
175
|
+
case 'message':
|
|
176
|
+
await this.flush()
|
|
177
|
+
await this.render(children, true)
|
|
178
|
+
break
|
|
179
|
+
default:
|
|
180
|
+
await this.render(children)
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export { LarkMessageEncoder as FeishuMessageEncoder }
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { BaseResponse, Internal } from '.'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Lark defines three types of token:
|
|
5
|
+
* - app_access_token: to access the API in an app (published on App Store).
|
|
6
|
+
* - tenant_access_token: to access the API as an enterprise or a team (tenant).
|
|
7
|
+
* *We commonly use this one*
|
|
8
|
+
* - user_access_token: to access the API as the specific user.
|
|
9
|
+
*
|
|
10
|
+
* @see https://open.larksuite.com/document/ukTMukTMukTM/uMTNz4yM1MjLzUzM
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export interface AppCredentials {
|
|
14
|
+
app_id: string
|
|
15
|
+
app_secret: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface AppAccessToken extends BaseResponse {
|
|
19
|
+
/** access token */
|
|
20
|
+
app_access_token: string
|
|
21
|
+
/** expire time in seconds. e.g: 7140 (119 minutes) */
|
|
22
|
+
expire: number
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface TenantAccessToken extends BaseResponse {
|
|
26
|
+
/** access token */
|
|
27
|
+
tenant_access_token: string
|
|
28
|
+
/** expire time in seconds. e.g: 7140 (119 minutes) */
|
|
29
|
+
expire: number
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
declare module './internal' {
|
|
33
|
+
export interface Internal {
|
|
34
|
+
/**
|
|
35
|
+
* Returns the app_access_token for the bot.
|
|
36
|
+
* @see https://open.larksuite.com/document/ukTMukTMukTM/ukDNz4SO0MjL5QzM/auth-v3/auth/app_access_token_internal
|
|
37
|
+
*/
|
|
38
|
+
getAppAccessToken(data: AppCredentials): Promise<AppAccessToken>
|
|
39
|
+
/**
|
|
40
|
+
* Returns the tenant_access_token for the bot.
|
|
41
|
+
* @see https://open.larksuite.com/document/ukTMukTMukTM/ukDNz4SO0MjL5QzM/auth-v3/auth/tenant_access_token_internal
|
|
42
|
+
*/
|
|
43
|
+
getTenantAccessToken(data: AppCredentials): Promise<TenantAccessToken>
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
Internal.define({
|
|
48
|
+
'/auth/v3/app_access_token/internal': {
|
|
49
|
+
POST: 'getAppAccessToken',
|
|
50
|
+
},
|
|
51
|
+
'/auth/v3/tenant_access_token/internal': {
|
|
52
|
+
POST: 'getTenantAccessToken',
|
|
53
|
+
},
|
|
54
|
+
})
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export interface EventHeader<T extends string> {
|
|
2
|
+
event_id: string
|
|
3
|
+
event_type: T
|
|
4
|
+
create_time: string
|
|
5
|
+
token: string
|
|
6
|
+
app_id: string
|
|
7
|
+
tenant_key: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface Events {}
|
|
11
|
+
export type EventName = keyof Events
|
|
12
|
+
|
|
13
|
+
// In fact, this is the 2.0 version of the event sent by Lark.
|
|
14
|
+
// And only the 2.0 version has the `schema` field.
|
|
15
|
+
export type EventSkeleton<T extends EventName, Event, Header = EventHeader<T>> = {
|
|
16
|
+
schema: '2.0'
|
|
17
|
+
type: T
|
|
18
|
+
header: Header
|
|
19
|
+
event: Event
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type AllEvents = Events[EventName]
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { Dict } from '@satorijs/satori'
|
|
2
|
+
|
|
3
|
+
import { Lark } from '.'
|
|
4
|
+
import { Internal } from './internal'
|
|
5
|
+
import { Paginated, Pagination } from './utils'
|
|
6
|
+
|
|
7
|
+
declare module '.' {
|
|
8
|
+
export namespace Lark {
|
|
9
|
+
export interface Guild {
|
|
10
|
+
avatar: string
|
|
11
|
+
name: string
|
|
12
|
+
description: string
|
|
13
|
+
i18n_names: Dict<string>
|
|
14
|
+
add_member_permission: string
|
|
15
|
+
share_card_permission: string
|
|
16
|
+
at_all_permission: string
|
|
17
|
+
edit_permission: string
|
|
18
|
+
owner_id_type: string
|
|
19
|
+
owner_id: string
|
|
20
|
+
chat_id: string
|
|
21
|
+
chat_mode: string
|
|
22
|
+
chat_type: string
|
|
23
|
+
chat_tag: string
|
|
24
|
+
join_message_visibility: string
|
|
25
|
+
leave_message_visibility: string
|
|
26
|
+
membership_approval: string
|
|
27
|
+
moderation_permission: string
|
|
28
|
+
external: boolean
|
|
29
|
+
tenant_key: string
|
|
30
|
+
user_count: string
|
|
31
|
+
bot_count: string
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface GuildMember {
|
|
37
|
+
member_id_type: Lark.UserIdType
|
|
38
|
+
member_id: string
|
|
39
|
+
name: string
|
|
40
|
+
tenant_key: string
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
declare module './internal' {
|
|
44
|
+
export interface Internal {
|
|
45
|
+
/** @see https://open.larksuite.com/document/uAjLw4CM/ukTMukTMukTM/reference/im-v1/chat/list */
|
|
46
|
+
getCurrentUserGuilds(params?: Pagination<{ user_id_type?: Lark.UserIdType }>): Promise<{ data: Paginated<Lark.Guild> }>
|
|
47
|
+
/** @see https://open.larksuite.com/document/uAjLw4CM/ukTMukTMukTM/reference/im-v1/chat/get */
|
|
48
|
+
getGuildInfo(chat_id: string, params?: { user_id_type?: string }): Promise<BaseResponse & { data: Lark.Guild }>
|
|
49
|
+
/** @see https://open.larksuite.com/document/uAjLw4CM/ukTMukTMukTM/reference/im-v1/chat-members/get */
|
|
50
|
+
getGuildMembers(chat_id: string, params?: Pagination<{ member_id_type?: Lark.UserIdType }>): Promise<{ data: Paginated<GuildMember> }>
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
Internal.define({
|
|
55
|
+
'/im/v1/chats': {
|
|
56
|
+
GET: 'getCurrentUserGuilds',
|
|
57
|
+
},
|
|
58
|
+
'/im/v1/chats/{chat_id}': {
|
|
59
|
+
GET: 'getGuildInfo',
|
|
60
|
+
},
|
|
61
|
+
'/im/v1/chats/{chat_id}/members': {
|
|
62
|
+
GET: 'getGuildMembers',
|
|
63
|
+
},
|
|
64
|
+
})
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
export * from './internal'
|
|
2
|
+
export * from './auth'
|
|
3
|
+
export * from './event'
|
|
4
|
+
export * from './guild'
|
|
5
|
+
export * from './message'
|
|
6
|
+
|
|
7
|
+
export namespace Lark {
|
|
8
|
+
/**
|
|
9
|
+
* A user in Lark has several different IDs.
|
|
10
|
+
* @see https://open.larksuite.com/document/home/user-identity-introduction/introduction
|
|
11
|
+
*/
|
|
12
|
+
export interface UserIds {
|
|
13
|
+
union_id: string
|
|
14
|
+
/** *user_id* only available when the app has permissions granted by the administrator */
|
|
15
|
+
user_id?: string
|
|
16
|
+
open_id: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Identify a user in Lark.
|
|
21
|
+
* This behaves like {@link Lark.UserIds}, but it only contains *open_id*.
|
|
22
|
+
* (i.e. the id_type is always `open_id`)
|
|
23
|
+
*/
|
|
24
|
+
export interface UserIdentifiers {
|
|
25
|
+
id: string
|
|
26
|
+
id_type: string
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export type UserIdType = 'union_id' | 'user_id' | 'open_id'
|
|
30
|
+
/**
|
|
31
|
+
* The id type when specify a receiver, would be used in the request query.
|
|
32
|
+
*
|
|
33
|
+
* NOTE: we always use **open_id** to identify a user, use **chat_id** to identify a channel.
|
|
34
|
+
* @see https://open.larksuite.com/document/uAjLw4CM/ukTMukTMukTM/reference/im-v1/message/create
|
|
35
|
+
*/
|
|
36
|
+
export type ReceiveIdType = UserIdType | 'email' | 'chat_id'
|
|
37
|
+
|
|
38
|
+
export type DepartmentIdType = 'department_id' | 'open_department_id'
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export { Lark as Feishu }
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import FormData from 'form-data'
|
|
2
|
+
import { Dict, makeArray, Quester } from '@satorijs/satori'
|
|
3
|
+
import { LarkBot } from '../bot'
|
|
4
|
+
|
|
5
|
+
export interface Internal {}
|
|
6
|
+
|
|
7
|
+
export interface BaseResponse {
|
|
8
|
+
/** error code. would be 0 if success, and non-0 if failed. */
|
|
9
|
+
code: number
|
|
10
|
+
/** error message. would be 'success' if success. */
|
|
11
|
+
msg: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
type Method = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'
|
|
15
|
+
|
|
16
|
+
export class Internal {
|
|
17
|
+
constructor(private bot: LarkBot) {}
|
|
18
|
+
|
|
19
|
+
private processReponse(response: any): BaseResponse {
|
|
20
|
+
const { code, msg } = response
|
|
21
|
+
if (code === 0) {
|
|
22
|
+
return response
|
|
23
|
+
} else {
|
|
24
|
+
this.bot.logger.debug('response: %o', response)
|
|
25
|
+
throw new Error(`HTTP response with non-zero status (${code}) with message "${msg}"`)
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
static define(routes: Dict<Partial<Record<Method, string | string[]>>>) {
|
|
30
|
+
for (const path in routes) {
|
|
31
|
+
for (const key in routes[path]) {
|
|
32
|
+
const method = key as Method
|
|
33
|
+
for (const name of makeArray(routes[path][method])) {
|
|
34
|
+
Internal.prototype[name] = async function (this: Internal, ...args: any[]) {
|
|
35
|
+
const raw = args.join(', ')
|
|
36
|
+
const url = path.replace(/\{([^}]+)\}/g, () => {
|
|
37
|
+
if (!args.length) throw new Error(`too few arguments for ${path}, received ${raw}`)
|
|
38
|
+
return args.shift()
|
|
39
|
+
})
|
|
40
|
+
const config: Quester.AxiosRequestConfig = {}
|
|
41
|
+
if (args.length === 1) {
|
|
42
|
+
if (method === 'GET' || method === 'DELETE') {
|
|
43
|
+
config.params = args[0]
|
|
44
|
+
} else {
|
|
45
|
+
if (method === 'POST' && args[0] instanceof FormData) {
|
|
46
|
+
config.headers = args[0].getHeaders()
|
|
47
|
+
}
|
|
48
|
+
config.data = args[0]
|
|
49
|
+
}
|
|
50
|
+
} else if (args.length === 2 && method !== 'GET' && method !== 'DELETE') {
|
|
51
|
+
config.data = args[0]
|
|
52
|
+
config.params = args[1]
|
|
53
|
+
} else if (args.length > 1) {
|
|
54
|
+
throw new Error(`too many arguments for ${path}, received ${raw}`)
|
|
55
|
+
}
|
|
56
|
+
return this.processReponse(await this.bot.http(method, url, config))
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import FormData from 'form-data'
|
|
2
|
+
|
|
3
|
+
import { BaseResponse, Internal } from '..'
|
|
4
|
+
|
|
5
|
+
export interface Asset<T> extends BaseResponse {
|
|
6
|
+
data: T
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export type Image = Asset<{ image_key: string }>
|
|
10
|
+
export type File = Asset<{ file_key: string }>
|
|
11
|
+
|
|
12
|
+
declare module '../internal' {
|
|
13
|
+
interface Internal {
|
|
14
|
+
/**
|
|
15
|
+
* Upload an image to obtain an `image_key` for use in sending messages or changing the avatar.
|
|
16
|
+
*
|
|
17
|
+
* The data should contain:
|
|
18
|
+
* - `image_type`: 'message' | 'avatar'
|
|
19
|
+
* - `image': Buffer
|
|
20
|
+
* @see https://open.larksuite.com/document/uAjLw4CM/ukTMukTMukTM/reference/im-v1/image/create
|
|
21
|
+
*/
|
|
22
|
+
uploadImage(data: FormData): Promise<Image>
|
|
23
|
+
/**
|
|
24
|
+
* Upload a file to obtain a `file_key` for use in sending messages.
|
|
25
|
+
*
|
|
26
|
+
* The data should contain:
|
|
27
|
+
* - `file_type`: 'opus' | 'mp4' | 'pdf' | 'xls' | 'ppt' | 'stream'
|
|
28
|
+
* - `opus`: Opus audio file
|
|
29
|
+
* - `mp4`: MP4 video file
|
|
30
|
+
* - `pdf`: PDF file
|
|
31
|
+
* - `xls`: Excel file
|
|
32
|
+
* - `ppt`: PowerPoint file
|
|
33
|
+
* - `stream`: Stream file, or any other file not listed above
|
|
34
|
+
* - `file_name`: string, include extension
|
|
35
|
+
* - `duration`?: number, the duration of audio/video file in milliseconds
|
|
36
|
+
* - `file`: Buffer
|
|
37
|
+
* @see https://open.larksuite.com/document/uAjLw4CM/ukTMukTMukTM/reference/im-v1/file/create
|
|
38
|
+
*/
|
|
39
|
+
uploadFile(data: FormData): Promise<File>
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
Internal.define({
|
|
44
|
+
'/im/v1/images': {
|
|
45
|
+
POST: 'uploadImage',
|
|
46
|
+
},
|
|
47
|
+
'/im/v1/files': {
|
|
48
|
+
POST: 'uploadFile',
|
|
49
|
+
},
|
|
50
|
+
})
|