Skip to content
Open
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
115 changes: 22 additions & 93 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,12 @@
[![CI](https://github.com/SaltifyDev/milky-tea/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/SaltifyDev/milky-tea/actions/workflows/ci.yml)
[![Coverage](https://img.shields.io/endpoint?url=https%3A%2F%2Fraw.githubusercontent.com%2FSaltifyDev%2Fmilky-tea%2Fbadges%2Fcoverage-badge.json)](https://github.com/SaltifyDev/milky-tea/actions/workflows/ci.yml)

Milky 的 TypeScript SDK,提供类型安全的 API 调用和事件流支持
Milky 的 TypeScript SDK,提供类型安全的 API 调用和事件解析

## 安装

```bash
npm i @saltify/milky-tea @saltify/milky-types
```

如果运行环境不支持 EventSource(例如 Node.js 环境)且需要 SSE 支持,则需要安装 `eventsource`:

```bash
npm i eventsource
pnpm add @saltify/milky-tea zod
```

## 使用方法
Expand Down Expand Up @@ -46,99 +40,28 @@ await client.group.quitGroup({ group_id: 10001 }, { timeout: false })

在这里,第二个参数是可选的,可以覆盖默认的 `baseURL`、`token`、`timeout` 等设置。

### 监听事件
### 解析事件

通过 `client.event()` 创建一个事件连接,支持 WebSocket 和 SSE 两种连接方式。连接模式有如下几种:

- `websocket`:仅使用 WebSocket
- `sse`:仅使用 Server-Sent Events
- `auto`:兼容旧版本的保留值,不再支持,传入后会报错;请显式使用 `websocket` 或 `sse`
SDK 不负责创建或管理事件连接。通过 SSE、WebSocket、WebHook 或其他方式收到事件后,将反序列化后的对象传给 `resolveMilkyEvent`:

```ts
const source = client.event('websocket', {
reconnect: {
interval: 1000,
attempts: 'always',
},
})

// 监听连接打开
source.on('open', () => {
console.log('connected')
})

// 监听所有事件
source.on('push', (event) => {
console.log(event.event_type, event)
})
import { resolveMilkyEvent } from '@saltify/milky-tea/event'

// 监听特定类型的事件
source.on('foobar', (event) => {
console.log(event.message.content)
})
const event = await resolveMilkyEvent(JSON.parse(payload))

// 监听错误
source.on('error', (event) => {
console.error(event.message)
})

// 使用 async iteration
for await (const event of source) {
console.log(event.event_type)
if (shouldStop)
switch (event.event_type) {
case 'message_receive':
console.log(event.data)
break
case 'bot_offline':
console.log(event.data.reason)
break
}

source.close()
```

**注意**: 事件对象是深度只读的(immutable),所有嵌套属性都被冻结,无法修改。

### `createMilkyEventSource`

如果需要更底层的事件源控制,可以使用 `createMilkyEventSource` 直接创建事件源。

```ts
import { createMilkyEventSource } from '@saltify/milky-tea'

// 使用连接类型和选项
const source = createMilkyEventSource('websocket', {
baseURL: 'https://milky.example.com',
token: process.env.MILKY_TOKEN,
timeout: 15000,
reconnect: {
interval: 1000,
attempts: 5,
},
})

// 或使用自定义传输工厂
const source = createMilkyEventSource(
async (options, signal) => {
// 返回 WebSocket 或 EventSource 实例
return new WebSocket('wss://milky.example.com/event')
},
{
timeout: 10000,
},
)

source.on('open', () => console.log('Connected'))
source.on('push', event => console.log(event))
source.close()
```

**参数**:
也可以从包根入口导入。推荐使用 `@saltify/milky-tea/event`,以便打包器完全隔离客户端代码和 API schema。

- `kind`: 连接类型 (`'websocket'` | `'sse'`;`'auto'` 为兼容保留值,传入会报错)
- `factory`: 自定义传输工厂函数
- `options`:
- `baseURL`: 服务器地址(使用 kind 时必需)
- `token`: 访问令牌
- `timeout`: 连接超时时间(默认 15000ms)
- `reconnect`: 重连配置
- `interval`: 重连间隔(毫秒)
- `attempts`: 重连次数(`'always'` 或数字)
`resolveMilkyEvent` 使用生成的 Zod schema 校验输入。校验结果会移除未知字段并返回深拷贝,但不会冻结返回对象;校验失败时会抛出带有 Zod 错误原因的异常。

### `createMilkyFetch`

Expand All @@ -149,14 +72,20 @@ import { createMilkyFetch } from '@saltify/milky-tea'

const milkyFetch = createMilkyFetch({
baseURL: 'https://milky.example.com',
strict: false,
zod: false,
})

const login = await milkyFetch('get_login_info', undefined)
console.log(login.uin)
```

`strict` 默认为 `true`。关闭后会跳过请求参数和响应数据的 zod 校验;也可以在单次请求的 override 里单独设置。
`zod` 默认为 `true`。关闭后会跳过请求参数和响应数据的 Zod 校验;也可以在单次请求的 override 里单独设置。

## 示例

- [`examples/client.ts`](./examples/client.ts):收到好友私聊事件后,将其中的文本消息 echo 给发送者
- [`examples/fetch.ts`](./examples/fetch.ts):使用底层 `createMilkyFetch` 调用原始 endpoint
- [`examples/event.ts`](./examples/event.ts):解析事件并通过 `event_type` 缩窄事件数据类型

## 开发

Expand Down
62 changes: 62 additions & 0 deletions examples/client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import type { OutgoingSegment } from '@saltify/milky-tea'
import process from 'node:process'
import { createMilkyClient } from '@saltify/milky-tea'
import { resolveMilkyEvent } from '@saltify/milky-tea/event'
import { EventSource } from 'eventsource'

const baseURL = process.env.MILKY_BASE_URL ?? 'https://milky.example.com'
const token = process.env.MILKY_TOKEN

const client = createMilkyClient({
baseURL,
token,
})

export async function handleEventPayload(payload: string): Promise<void> {
const rawEvent: unknown = JSON.parse(payload)
const event = await resolveMilkyEvent(rawEvent)

if (
event.event_type !== 'message_receive'
|| event.data.message_scene !== 'friend'
) {
return
}

// Incoming and outgoing segment unions are intentionally different. This
// example echoes the text segments that are valid in both directions.
const message: OutgoingSegment[] = event.data.segments
.filter(segment => segment.type === 'text')

if (message.length === 0) {
return
}

await client.message.sendPrivateMessage({
user_id: event.data.sender_id,
message,
})
}

const eventURL = new URL('/event', baseURL)
if (token) {
eventURL.searchParams.set('access_token', token)
}

const eventSource = new EventSource(eventURL)

eventSource.addEventListener('milky_event', (event) => {
handleEventPayload(String(event.data)).catch(reportError)
})

// EventSource reconnects automatically after recoverable connection failures.
eventSource.onerror = (event) => {
reportError(event.message ?? `EventSource error${event.code ? ` (${event.code})` : ''}`)
}

process.once('SIGINT', () => eventSource.close())
process.once('SIGTERM', () => eventSource.close())

function reportError(error: unknown): void {
process.stderr.write(`${String(error)}\n`)
}
41 changes: 0 additions & 41 deletions examples/echo.ts

This file was deleted.

28 changes: 28 additions & 0 deletions examples/event.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { resolveMilkyEvent } from '@saltify/milky-tea/event'

type EmitEvent = (summary: string, details?: unknown) => void

export async function handleEventPayload(
payload: string,
emit: EmitEvent,
): Promise<void> {
const rawEvent: unknown = JSON.parse(payload)
const event = await resolveMilkyEvent(rawEvent)

// event_type narrows both the event and its data payload.
switch (event.event_type) {
case 'message_receive':
emit(
`Message ${event.data.message_seq} from ${event.data.sender_id}`,
event.data.segments,
)
break

case 'bot_offline':
emit(`Bot ${event.self_id} went offline: ${event.data.reason}`)
break

default:
emit(`Received ${event.event_type}`)
}
}
23 changes: 23 additions & 0 deletions examples/fetch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import type { GetFriendInfoOutput } from '@saltify/milky-tea'
import process from 'node:process'
import { createMilkyFetch } from '@saltify/milky-tea'

async function main(): Promise<void> {
const milkyFetch = createMilkyFetch({
baseURL: 'https://milky.example.com',
token: process.env.MILKY_TOKEN,
})

// Use the raw snake_case endpoint name when grouped client methods are not
// suitable. Request and response types are still inferred from the endpoint.
const result: GetFriendInfoOutput = await milkyFetch('get_friend_info', {
user_id: 10001,
})

process.stdout.write(`${JSON.stringify(result.friend, null, 2)}\n`)
}

main().catch((error: unknown) => {
process.stderr.write(`${String(error)}\n`)
process.exitCode = 1
})
18 changes: 10 additions & 8 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,16 @@
"type": "git",
"url": "git+https://github.com/SaltifyDev/milky-tea.git"
},
"sideEffects": false,
"exports": {
".": "./dist/index.mjs",
".": {
"types": "./dist/index.d.mts",
"import": "./dist/index.mjs"
},
"./event": {
"types": "./dist/event.d.mts",
"import": "./dist/event.mjs"
},
"./package.json": "./package.json"
},
"types": "./dist/index.d.mts",
Expand All @@ -24,27 +32,21 @@
"generate-api": "sh ./scripts/fetch-types.sh",
"test": "vitest run",
"test:coverage": "vitest run --coverage",
"check:bundle": "pnpm run build && pnpm exec jiti ./scripts/check-build.ts",
"typecheck": "tsc --noEmit",
"prepublishOnly": "pnpm run build",
"fmt": "eslint . --fix",
"prepare": "pnpm run generate-api",
"bump": "bumpp"
},
"peerDependencies": {
"eventsource": "^4.1.0",
"zod": "^4.4.3"
},
"peerDependenciesMeta": {
"eventsource": {
"optional": true
},
"zod": {
"optional": true
}
},
"dependencies": {
"mitt": "^3.0.1"
},
"devDependencies": {
"@antfu/eslint-config": "^7.7.3",
"@types/node": "^26.1.2",
Expand Down
9 changes: 0 additions & 9 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading