Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
120 changes: 120 additions & 0 deletions packages/chat/src/chat-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import {
type Message,
type ReceiveIdType,
ReplyMessageResponseSchema,
UploadFileResponseSchema,
UploadImageResponseSchema,
parseResponse,
} from '@lark-kit/shared'

Expand Down Expand Up @@ -50,6 +52,9 @@ export interface ListChatsResult {
page_token?: string
}

export type ImageType = 'message' | 'avatar'
export type FileType = 'opus' | 'mp4' | 'pdf' | 'doc' | 'xls' | 'ppt' | 'stream'

export class MessageClient {
constructor(
private readonly httpClient: HttpClient,
Expand Down Expand Up @@ -145,6 +150,42 @@ export class MessageClient {
},
})
}

/**
* Convenience method to send an image message
*/
async sendImage(
receiveIdType: ReceiveIdType,
receiveId: string,
imageKey: string
): Promise<Message> {
return this.create({
params: { receive_id_type: receiveIdType },
data: {
receive_id: receiveId,
msg_type: 'image',
content: JSON.stringify({ image_key: imageKey }),
},
})
}

/**
* Convenience method to send a file message
*/
async sendFile(
receiveIdType: ReceiveIdType,
receiveId: string,
fileKey: string
): Promise<Message> {
return this.create({
params: { receive_id_type: receiveIdType },
data: {
receive_id: receiveId,
msg_type: 'file',
content: JSON.stringify({ file_key: fileKey }),
},
})
}
}

export class ChatClient {
Expand Down Expand Up @@ -185,12 +226,91 @@ export class ChatClient {
}
}

export class ImageClient {
constructor(
private readonly httpClient: HttpClient,
private readonly tokenManager: TokenManager
) {}

private async getAuthHeaders(): Promise<Record<string, string>> {
const token = await this.tokenManager.getTenantAccessToken()
return {
Authorization: `Bearer ${token}`,
}
}

/**
* Upload an image to Lark
* @see https://open.feishu.cn/document/uAjLw4CM/ukTMukTMukTM/reference/im-v1/image/create
*/
async upload(image: Blob | Buffer, imageType: ImageType = 'message'): Promise<string> {
const headers = await this.getAuthHeaders()

const formData = new FormData()
formData.append('image_type', imageType)
formData.append('image', image)

const response = await this.httpClient.post('/open-apis/im/v1/images', {
headers,
formData,
})

const parsed = parseResponse(UploadImageResponseSchema, response)
if (parsed.code !== 0) {
throw new LarkApiError(`Failed to upload image: ${parsed.msg}`, parsed.code || 0)
}
return parsed.data?.image_key ?? ''
}
}

export class FileClient {
constructor(
private readonly httpClient: HttpClient,
private readonly tokenManager: TokenManager
) {}

private async getAuthHeaders(): Promise<Record<string, string>> {
const token = await this.tokenManager.getTenantAccessToken()
return {
Authorization: `Bearer ${token}`,
}
}

/**
* Upload a file to Lark
* @see https://open.feishu.cn/document/uAjLw4CM/ukTMukTMukTM/reference/im-v1/file/create
*/
async upload(file: Blob | Buffer, fileName: string, fileType: FileType): Promise<string> {
const headers = await this.getAuthHeaders()

const formData = new FormData()
formData.append('file_type', fileType)
formData.append('file_name', fileName)
formData.append('file', file)

const response = await this.httpClient.post('/open-apis/im/v1/files', {
headers,
formData,
})

const parsed = parseResponse(UploadFileResponseSchema, response)
if (parsed.code !== 0) {
throw new LarkApiError(`Failed to upload file: ${parsed.msg}`, parsed.code || 0)
}
return parsed.data?.file_key ?? ''
}
}

export class ImClient {
public readonly message: MessageClient
public readonly chat: ChatClient
public readonly image: ImageClient
public readonly file: FileClient

constructor(httpClient: HttpClient, tokenManager: TokenManager) {
this.message = new MessageClient(httpClient, tokenManager)
this.chat = new ChatClient(httpClient, tokenManager)
this.image = new ImageClient(httpClient, tokenManager)
this.file = new FileClient(httpClient, tokenManager)
}
}
4 changes: 3 additions & 1 deletion packages/chat/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
export { ImClient, MessageClient, ChatClient } from './chat-client'
export { ImClient, MessageClient, ChatClient, ImageClient, FileClient } from './chat-client'
export type {
CreateMessagePayload,
ReplyMessagePayload,
ListChatsPayload,
ListChatsResult,
ImageType,
FileType,
} from './chat-client'
18 changes: 15 additions & 3 deletions packages/core/src/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export interface RequestConfig {
data?: unknown
params?: Record<string, string | number | boolean | undefined>
path?: Record<string, string>
formData?: FormData
}

export type RequestHook = (config: RequestConfig) => void
Expand Down Expand Up @@ -46,7 +47,7 @@ export class HttpClient {
}

async request<T>(config: RequestConfig): Promise<T> {
const { method, data, params, path, headers: configHeaders } = config
const { method, data, params, path, headers: configHeaders, formData } = config

const filledPath = fillApiPath(config.url, path)
const queryString = buildQueryString(params)
Expand All @@ -57,15 +58,26 @@ export class HttpClient {
this.onRequest?.(config)

const headers: Record<string, string> = {
'Content-Type': 'application/json',
'User-Agent': 'lark-kit/1.0.0',
...configHeaders,
}

// Don't set Content-Type for FormData (browser will set it with boundary)
if (!formData) {
headers['Content-Type'] = 'application/json'
}

let body: string | FormData | undefined
if (formData) {
body = formData
} else if (data) {
body = JSON.stringify(data)
}

const response = await fetch(url, {
method,
headers,
body: data ? JSON.stringify(data) : undefined,
body,
})

const responseData = await response.json()
Expand Down
11 changes: 9 additions & 2 deletions packages/lark-kit/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,12 @@ export type {
} from '@lark-kit/core'

// Re-export from bitable
export { BitableClient, AppTableRecordClient, AppTableFieldClient, FieldType } from '@lark-kit/bitable'
export {
BitableClient,
AppTableRecordClient,
AppTableFieldClient,
FieldType,
} from '@lark-kit/bitable'
export type {
CreateRecordPayload,
GetRecordPayload,
Expand All @@ -43,12 +48,14 @@ export type {
} from '@lark-kit/bitable'

// Re-export from chat
export { ImClient, MessageClient, ChatClient } from '@lark-kit/chat'
export { ImClient, MessageClient, ChatClient, ImageClient, FileClient } from '@lark-kit/chat'
export type {
CreateMessagePayload,
ReplyMessagePayload,
ListChatsPayload,
ListChatsResult,
ImageType,
FileType,
} from '@lark-kit/chat'

// Re-export types from shared
Expand Down
8 changes: 4 additions & 4 deletions packages/lark-kit/src/lark.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import { BitableClient } from '@lark-kit/bitable'
import { ImClient } from '@lark-kit/chat'
import {
Domain,
HttpClient,
TokenManager,
InMemoryTokenStorage,
Domain,
type LarkConfig,
TokenManager,
} from '@lark-kit/core'
import { BitableClient } from '@lark-kit/bitable'
import { ImClient } from '@lark-kit/chat'

export class Client {
public readonly bitable: BitableClient
Expand Down
23 changes: 23 additions & 0 deletions packages/shared/src/schemas/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,29 @@ export type ReplyMessageResponse = z.infer<typeof ReplyMessageResponseSchema>
export type Chat = z.infer<typeof ChatSchema>
export type ListChatsResponse = z.infer<typeof ListChatsResponseSchema>

export const UploadImageResponseSchema = z.object({
code: z.number().optional(),
msg: z.string().optional(),
data: z
.object({
image_key: z.string().optional(),
})
.optional(),
})

export const UploadFileResponseSchema = z.object({
code: z.number().optional(),
msg: z.string().optional(),
data: z
.object({
file_key: z.string().optional(),
})
.optional(),
})

export type UploadImageResponse = z.infer<typeof UploadImageResponseSchema>
export type UploadFileResponse = z.infer<typeof UploadFileResponseSchema>

// Card types for interactive messages
export interface CardHeader {
title: {
Expand Down