chatboty 0.1.0
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/LICENSE +162 -0
- package/README.md +119 -0
- package/dist/cjs/package.json +3 -0
- package/dist/cjs/src/chatboty.d.ts +93 -0
- package/dist/cjs/src/chatboty.d.ts.map +1 -0
- package/dist/cjs/src/chatboty.js +204 -0
- package/dist/cjs/src/chatboty.js.map +1 -0
- package/dist/cjs/src/client.d.ts +84 -0
- package/dist/cjs/src/client.d.ts.map +1 -0
- package/dist/cjs/src/client.js +298 -0
- package/dist/cjs/src/client.js.map +1 -0
- package/dist/cjs/src/entity/contact.d.ts +25 -0
- package/dist/cjs/src/entity/contact.d.ts.map +1 -0
- package/dist/cjs/src/entity/contact.js +34 -0
- package/dist/cjs/src/entity/contact.js.map +1 -0
- package/dist/cjs/src/entity/friendship.d.ts +13 -0
- package/dist/cjs/src/entity/friendship.d.ts.map +1 -0
- package/dist/cjs/src/entity/friendship.js +19 -0
- package/dist/cjs/src/entity/friendship.js.map +1 -0
- package/dist/cjs/src/entity/message.d.ts +61 -0
- package/dist/cjs/src/entity/message.d.ts.map +1 -0
- package/dist/cjs/src/entity/message.js +150 -0
- package/dist/cjs/src/entity/message.js.map +1 -0
- package/dist/cjs/src/entity/room.d.ts +21 -0
- package/dist/cjs/src/entity/room.d.ts.map +1 -0
- package/dist/cjs/src/entity/room.js +43 -0
- package/dist/cjs/src/entity/room.js.map +1 -0
- package/dist/cjs/src/mod.d.ts +17 -0
- package/dist/cjs/src/mod.d.ts.map +1 -0
- package/dist/cjs/src/mod.js +41 -0
- package/dist/cjs/src/mod.js.map +1 -0
- package/dist/cjs/src/types.d.ts +122 -0
- package/dist/cjs/src/types.d.ts.map +1 -0
- package/dist/cjs/src/types.js +55 -0
- package/dist/cjs/src/types.js.map +1 -0
- package/dist/esm/src/chatboty.d.ts +93 -0
- package/dist/esm/src/chatboty.d.ts.map +1 -0
- package/dist/esm/src/chatboty.js +200 -0
- package/dist/esm/src/chatboty.js.map +1 -0
- package/dist/esm/src/client.d.ts +84 -0
- package/dist/esm/src/client.d.ts.map +1 -0
- package/dist/esm/src/client.js +291 -0
- package/dist/esm/src/client.js.map +1 -0
- package/dist/esm/src/entity/contact.d.ts +25 -0
- package/dist/esm/src/entity/contact.d.ts.map +1 -0
- package/dist/esm/src/entity/contact.js +30 -0
- package/dist/esm/src/entity/contact.js.map +1 -0
- package/dist/esm/src/entity/friendship.d.ts +13 -0
- package/dist/esm/src/entity/friendship.d.ts.map +1 -0
- package/dist/esm/src/entity/friendship.js +15 -0
- package/dist/esm/src/entity/friendship.js.map +1 -0
- package/dist/esm/src/entity/message.d.ts +61 -0
- package/dist/esm/src/entity/message.d.ts.map +1 -0
- package/dist/esm/src/entity/message.js +146 -0
- package/dist/esm/src/entity/message.js.map +1 -0
- package/dist/esm/src/entity/room.d.ts +21 -0
- package/dist/esm/src/entity/room.d.ts.map +1 -0
- package/dist/esm/src/entity/room.js +39 -0
- package/dist/esm/src/entity/room.js.map +1 -0
- package/dist/esm/src/mod.d.ts +17 -0
- package/dist/esm/src/mod.d.ts.map +1 -0
- package/dist/esm/src/mod.js +17 -0
- package/dist/esm/src/mod.js.map +1 -0
- package/dist/esm/src/types.d.ts +122 -0
- package/dist/esm/src/types.d.ts.map +1 -0
- package/dist/esm/src/types.js +50 -0
- package/dist/esm/src/types.js.map +1 -0
- package/package.json +52 -0
- package/src/chatboty.ts +228 -0
- package/src/client.ts +310 -0
- package/src/entity/contact.ts +32 -0
- package/src/entity/friendship.ts +16 -0
- package/src/entity/message.ts +147 -0
- package/src/entity/room.ts +42 -0
- package/src/mod.ts +19 -0
- package/src/types.ts +142 -0
package/package.json
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "chatboty",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "chatboty — 企业微信(WeCom)IM 自动化 SDK。自研全生态框架, 收发消息/群管理/群事件/联系人, 经云网关连接你的探针实例。",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/cjs/src/mod.js",
|
|
7
|
+
"types": "dist/esm/src/mod.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/esm/src/mod.d.ts",
|
|
11
|
+
"require": "./dist/cjs/src/mod.js",
|
|
12
|
+
"import": "./dist/esm/src/mod.js"
|
|
13
|
+
},
|
|
14
|
+
"./client": {
|
|
15
|
+
"types": "./dist/esm/src/client.d.ts",
|
|
16
|
+
"require": "./dist/cjs/src/client.js",
|
|
17
|
+
"import": "./dist/esm/src/client.js"
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
"files": [
|
|
21
|
+
"dist",
|
|
22
|
+
"src",
|
|
23
|
+
"LICENSE",
|
|
24
|
+
"README.md"
|
|
25
|
+
],
|
|
26
|
+
"scripts": {
|
|
27
|
+
"build": "tsc && tsc -p tsconfig.cjs.json && node ./scripts/fixup-cjs.mjs",
|
|
28
|
+
"type-check": "tsc --noEmit",
|
|
29
|
+
"clean": "rm -rf dist",
|
|
30
|
+
"prepublishOnly": "npm run clean && npm run build"
|
|
31
|
+
},
|
|
32
|
+
"engines": {
|
|
33
|
+
"node": ">=18"
|
|
34
|
+
},
|
|
35
|
+
"keywords": [
|
|
36
|
+
"chatboty",
|
|
37
|
+
"wecom",
|
|
38
|
+
"wxwork",
|
|
39
|
+
"企业微信",
|
|
40
|
+
"im",
|
|
41
|
+
"chatbot",
|
|
42
|
+
"automation"
|
|
43
|
+
],
|
|
44
|
+
"dependencies": {
|
|
45
|
+
"ws": "^8.18.0"
|
|
46
|
+
},
|
|
47
|
+
"devDependencies": {
|
|
48
|
+
"@types/ws": "^8.5.10",
|
|
49
|
+
"typescript": "^5.4.0"
|
|
50
|
+
},
|
|
51
|
+
"license": "Apache-2.0"
|
|
52
|
+
}
|
package/src/chatboty.ts
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chatboty — 自研机器人主类(零外部框架依赖)。
|
|
3
|
+
* 封装 ChatbotyClient(发证+连网关传输), 把 wire 事件/结果映射成实体类(Message/Contact/Room/Friendship),
|
|
4
|
+
* 对外暴露 typed 事件与便捷 API。这是 chatboty 全生态框架的核心。
|
|
5
|
+
*/
|
|
6
|
+
import { ChatbotyClient, type ChatbotyClientOptions } from './client.js'
|
|
7
|
+
import {
|
|
8
|
+
type BotApi, type Sayable, type MessagePayload, type ContactPayload, type ContactQueryFilter,
|
|
9
|
+
toMessageType, isRoomId,
|
|
10
|
+
} from './types.js'
|
|
11
|
+
import { Message } from './entity/message.js'
|
|
12
|
+
import { Contact } from './entity/contact.js'
|
|
13
|
+
import { Room } from './entity/room.js'
|
|
14
|
+
import { Friendship } from './entity/friendship.js'
|
|
15
|
+
|
|
16
|
+
export type ChatbotyOptions = ChatbotyClientOptions
|
|
17
|
+
|
|
18
|
+
type Listener = (...args: any[]) => void
|
|
19
|
+
|
|
20
|
+
export class Chatboty implements BotApi {
|
|
21
|
+
private readonly client: ChatbotyClient
|
|
22
|
+
private readonly listeners = new Map<string, Set<Listener>>()
|
|
23
|
+
private _self?: Contact
|
|
24
|
+
|
|
25
|
+
constructor (options: ChatbotyOptions) {
|
|
26
|
+
this.client = new ChatbotyClient(options)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** 该接入授权的探针实例 id。 */
|
|
30
|
+
get accountId (): string | undefined { return this.client.account }
|
|
31
|
+
|
|
32
|
+
/** 当前登录者(self)。start 后异步热身; 未就绪时 undefined(用 {@link currentUser} 主动拉取)。 */
|
|
33
|
+
get self (): Contact | undefined { return this._self }
|
|
34
|
+
|
|
35
|
+
async start (): Promise<void> {
|
|
36
|
+
this.wire()
|
|
37
|
+
await this.client.start()
|
|
38
|
+
this.emit('ready')
|
|
39
|
+
// 热身 self(供 message.mentionSelf/self 判定); 失败不致命(未登录时拉不到)。
|
|
40
|
+
void this.currentUser().catch(() => undefined)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async stop (): Promise<void> { this.client.stop(); this.emit('stop') }
|
|
44
|
+
|
|
45
|
+
/* ---------------- 事件 ---------------- */
|
|
46
|
+
on (event: 'message', l: (m: Message) => void): this
|
|
47
|
+
on (event: 'friendship', l: (f: Friendship) => void): this
|
|
48
|
+
on (event: 'room-join' | 'room-leave', l: (p: { room: Room, contactList: Contact[], idList: string[], timestamp?: number }) => void): this
|
|
49
|
+
on (event: 'room-topic', l: (p: { room: Room, topic?: string, oldTopic?: string, timestamp?: number }) => void): this
|
|
50
|
+
on (event: 'login', l: (self?: any) => void): this
|
|
51
|
+
on (event: 'scan', l: (p: { qrcode?: string, status?: any }) => void): this
|
|
52
|
+
// 登录阶段(扫码/验证码全流程): phase = scan|scanned|need-verify|verifying|verify-ok|verify-fail|online...
|
|
53
|
+
on (event: 'login-phase', l: (p: { phase: string, label?: string }) => void): this
|
|
54
|
+
// 探针/代理侧错误信号(不中断运行, 仅上报): { error }。
|
|
55
|
+
on (event: 'error', l: (p: { error: string }) => void): this
|
|
56
|
+
// 探针心跳(~30s 一次), 供上层做存活/掉线判定: { state }。
|
|
57
|
+
on (event: 'heartbeat', l: (p: { state?: string }) => void): this
|
|
58
|
+
on (event: 'logout' | 'ready' | 'stop' | 'disconnected', l: () => void): this
|
|
59
|
+
on (event: string, l: Listener): this {
|
|
60
|
+
if (!this.listeners.has(event)) this.listeners.set(event, new Set())
|
|
61
|
+
this.listeners.get(event)!.add(l)
|
|
62
|
+
return this
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
private emit (event: string, ...args: any[]): void {
|
|
66
|
+
this.listeners.get(event)?.forEach((l) => { try { l(...args) } catch { /* 隔离回调异常 */ } })
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
private wire (): void {
|
|
70
|
+
this.client.on('message', (w) => this.emit('message', this.toMessage(w)))
|
|
71
|
+
this.client.on('room-join', (w) => this.emit('room-join', this.toMemberEvent(w, w.inviteeIdList, w.inviteeList)))
|
|
72
|
+
this.client.on('room-leave', (w) => this.emit('room-leave', this.toMemberEvent(w, w.removeeIdList, w.removeeList)))
|
|
73
|
+
this.client.on('room-topic', (w) => this.emit('room-topic', {
|
|
74
|
+
room: new Room(this, { id: w.roomId, topic: w.topic }), topic: w.topic, oldTopic: w.oldTopic, timestamp: w.timestamp,
|
|
75
|
+
}))
|
|
76
|
+
this.client.on('friendship', (w) => this.emit('friendship', this.toFriendship(w)))
|
|
77
|
+
this.client.on('login', (w) => this.emit('login', w.self))
|
|
78
|
+
this.client.on('logout', () => { this._self = undefined; this.emit('logout') })
|
|
79
|
+
this.client.on('scan', (w) => this.emit('scan', { qrcode: w.qrcode, status: w.status }))
|
|
80
|
+
this.client.on('login-phase', (w) => this.emit('login-phase', { phase: w.phase, label: w.label }))
|
|
81
|
+
this.client.on('disconnected', () => this.emit('disconnected'))
|
|
82
|
+
this.client.on('error', (w) => this.emit('error', { error: w.error }))
|
|
83
|
+
this.client.on('heartbeat', (w) => this.emit('heartbeat', { state: w.state }))
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/* ---------------- wire → 实体 ---------------- */
|
|
87
|
+
private toContactPayload (u: any): ContactPayload {
|
|
88
|
+
return {
|
|
89
|
+
id: u.userId ?? u.id, name: u.name, alias: u.alias, avatar: u.avatar,
|
|
90
|
+
phone: u.phone, title: u.title, corpId: u.corpId, unionId: u.unionId, conversationId: u.conversationId,
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
private toMessage (w: any): Message {
|
|
95
|
+
const conv = w.conversationId
|
|
96
|
+
const room = isRoomId(conv)
|
|
97
|
+
const talker: ContactPayload | undefined = w.talkerUser
|
|
98
|
+
? this.toContactPayload(w.talkerUser)
|
|
99
|
+
: (w.talkerId ? { id: w.talkerId, name: w.talker } : undefined)
|
|
100
|
+
const payload: MessagePayload = {
|
|
101
|
+
id: w.msgId,
|
|
102
|
+
type: toMessageType(w.type, w.typeRaw),
|
|
103
|
+
text: w.text,
|
|
104
|
+
talkerId: talker?.id ?? w.talkerId,
|
|
105
|
+
roomId: room ? conv : undefined,
|
|
106
|
+
conversationId: conv,
|
|
107
|
+
timestamp: w.timestamp,
|
|
108
|
+
self: !!w.self,
|
|
109
|
+
talker,
|
|
110
|
+
file: w.file ?? null,
|
|
111
|
+
miniProgram: w.miniProgram ?? null,
|
|
112
|
+
urlLink: w.urlLink ?? null,
|
|
113
|
+
}
|
|
114
|
+
return new Message(this, payload)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
private toMemberEvent (w: any, idList: string[], userList: any[]) {
|
|
118
|
+
return {
|
|
119
|
+
room: new Room(this, { id: w.roomId }),
|
|
120
|
+
idList: idList || [],
|
|
121
|
+
contactList: (userList || []).filter(Boolean).map((u: any) => new Contact(this, this.toContactPayload(u))),
|
|
122
|
+
timestamp: w.timestamp,
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
private toFriendship (w: any): Friendship {
|
|
127
|
+
const kind = w.kind === 'add' ? 'add' : w.kind === 'delete' ? 'delete' : w.kind === 'receive' ? 'receive' : 'confirm'
|
|
128
|
+
return new Friendship(this, {
|
|
129
|
+
contactId: w.vid || w.contactId,
|
|
130
|
+
contact: w.user ? this.toContactPayload(w.user) : undefined,
|
|
131
|
+
kind,
|
|
132
|
+
hello: w.hello,
|
|
133
|
+
})
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/* ---------------- BotApi(实体动作底座) ---------------- */
|
|
137
|
+
async command (cmd: string, args: Record<string, any> = {}): Promise<any> {
|
|
138
|
+
return this.client.command(cmd, args)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** 登录扫码后若企微要求验证码, 用此提交(配合 on('login-phase') 的 need-verify 阶段)。 */
|
|
142
|
+
async enterVerifyCode (code: string): Promise<any> {
|
|
143
|
+
return this.client.command('enterVerifyCode', { code })
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** BotApi: 当前登录者 payload(供实体做本机 self 判定, 如 Message.mentionSelf)。 */
|
|
147
|
+
selfContact (): ContactPayload | undefined { return this._self?.payload }
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* 干净登出(回到扫码页, 不重启探针进程)。冷调 LoginServiceImpl 登出链,
|
|
151
|
+
* 完成后探针会推 'logout' 事件并转为出新二维码的扫码态。
|
|
152
|
+
*/
|
|
153
|
+
async logout (): Promise<void> {
|
|
154
|
+
await this.command('logout')
|
|
155
|
+
this._self = undefined
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* 拉取当前登录者(self)为完整 Contact, 并缓存到 {@link self}。
|
|
160
|
+
* @returns 当前登录者 Contact; 未登录/会话未加载时 undefined。
|
|
161
|
+
*/
|
|
162
|
+
async currentUser (): Promise<Contact | undefined> {
|
|
163
|
+
let r: any
|
|
164
|
+
try { r = await this.command('self') } catch { return this._self }
|
|
165
|
+
if (!r || !(r.userId || r.id)) return this._self
|
|
166
|
+
this._self = new Contact(this, this.toContactPayload(r))
|
|
167
|
+
return this._self
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async sayTo (conversationId: string, sayable: Sayable): Promise<any> {
|
|
171
|
+
if (typeof sayable === 'string') return this.client.command('sendText', { conversationId, text: sayable })
|
|
172
|
+
if ('image' in sayable) return this.client.command('sendImage', { conversationId, url: sayable.image })
|
|
173
|
+
if ('file' in sayable) return this.client.command('sendFile', { conversationId, url: sayable.file })
|
|
174
|
+
if ('url' in sayable) return this.client.command('sendUrlLink', { conversationId, url: sayable.url, title: sayable.title, desc: sayable.description, thumb: sayable.thumbUrl })
|
|
175
|
+
if ('miniProgram' in sayable) return this.client.command('sendWeAppCard', { conversationId, ...sayable.miniProgram })
|
|
176
|
+
throw new Error('不支持的发送内容')
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/* ---------------- 查询/工厂 ---------------- */
|
|
180
|
+
async roomList (): Promise<Room[]> {
|
|
181
|
+
const r = await this.command('roomList')
|
|
182
|
+
const arr = (r && (r.rooms || r.list || (Array.isArray(r) ? r : []))) || []
|
|
183
|
+
return arr.map((x: any) => new Room(this, { id: x.conversationId || x.id, topic: x.name || x.topic }))
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async contactList (): Promise<Contact[]> {
|
|
187
|
+
const r = await this.command('contactList')
|
|
188
|
+
const arr = (r && (r.contacts || r.list || (Array.isArray(r) ? r : []))) || []
|
|
189
|
+
return arr.map((u: any) => new Contact(this, this.toContactPayload(u)))
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* 在【已加载的全量通讯录】按条件查找联系人(本地过滤, 非服务端搜陌生人)。
|
|
194
|
+
* @param query 关键词字符串(匹配 name/alias/phone/title/vid 子串), 或 { name?, alias?, id? } 精确匹配。
|
|
195
|
+
* @returns 命中的 Contact 列表(可能为空)。
|
|
196
|
+
*/
|
|
197
|
+
async contactFindAll (query?: ContactQueryFilter): Promise<Contact[]> {
|
|
198
|
+
const keyword = typeof query === 'string'
|
|
199
|
+
? query
|
|
200
|
+
: (query?.name ?? query?.alias ?? query?.id ?? '')
|
|
201
|
+
const r = await this.command('contactSearch', { keyword })
|
|
202
|
+
const arr = (r && (r.contacts || r.list || (Array.isArray(r) ? r : []))) || []
|
|
203
|
+
let list = arr.map((u: any) => new Contact(this, this.toContactPayload(u)))
|
|
204
|
+
if (query && typeof query !== 'string') { // 对象条件: 在子串命中基础上再做精确收窄
|
|
205
|
+
if (query.id) list = list.filter((c: Contact) => c.id() === query.id)
|
|
206
|
+
if (query.alias) list = list.filter((c: Contact) => c.alias() === query.alias)
|
|
207
|
+
if (query.name) list = list.filter((c: Contact) => c.name() === query.name)
|
|
208
|
+
}
|
|
209
|
+
return list
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* 查找单个联系人(取 {@link contactFindAll} 第一个命中)。
|
|
214
|
+
* @param query 关键词字符串或 { name?, alias?, id? }。
|
|
215
|
+
* @returns 命中的第一个 Contact; 无则 undefined。
|
|
216
|
+
*/
|
|
217
|
+
async contactFind (query: ContactQueryFilter): Promise<Contact | undefined> {
|
|
218
|
+
const all = await this.contactFindAll(query)
|
|
219
|
+
return all[0]
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** 建群(成员 vid 列表)。 */
|
|
223
|
+
async roomCreate (memberIdList: string[], topic?: string): Promise<Room | undefined> {
|
|
224
|
+
const r = await this.command('roomCreate', { userList: memberIdList, topic })
|
|
225
|
+
const id = r && (r.conversationId || r.roomId || r.id)
|
|
226
|
+
return id ? new Room(this, { id, topic }) : undefined
|
|
227
|
+
}
|
|
228
|
+
}
|
package/src/client.ts
ADDED
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ChatbotyClient — P1 身份主线的"薄壳"接入客户端。
|
|
3
|
+
*
|
|
4
|
+
* 自研、零外部框架依赖(仅 ws)。职责:
|
|
5
|
+
* 1) 服务发现+发证: 用长期订阅 token 调售卖端 /api/access/grant 换取
|
|
6
|
+
* {endpoint(网关地址), accessToken(短TTL接入JWT), accountId}。
|
|
7
|
+
* 2) 连网关: endpoint + `/client?token=<accessToken>`; 网关公钥验签后按 accountId 路由+隔离。
|
|
8
|
+
* 3) 收发: 命令请求/回包关联(带超时) + 事件订阅(message/room-join/room-leave/room-topic/friendship/...)。
|
|
9
|
+
*
|
|
10
|
+
* 这是 P2 自研全生态框架(Contact/Room/Message/插件)的传输地基。
|
|
11
|
+
*/
|
|
12
|
+
import WebSocket from 'ws'
|
|
13
|
+
|
|
14
|
+
export interface ChatbotyClientOptions {
|
|
15
|
+
/**
|
|
16
|
+
* 长期订阅 token(cbt_...), 由售卖端签发给用户。
|
|
17
|
+
* 直连/发证模式(fallback)必填; 使用服务发现(serviceToken+serviceAuthority)时可省略。
|
|
18
|
+
*/
|
|
19
|
+
token?: string
|
|
20
|
+
/** 售卖端基址(发证/服务发现)。默认 http://127.0.0.1:8080 */
|
|
21
|
+
atlasUrl?: string
|
|
22
|
+
/** 命令回包超时(ms)。默认 25000 */
|
|
23
|
+
timeoutMs?: number
|
|
24
|
+
/** 调试直连: 跳过发证, 直接给网关地址 */
|
|
25
|
+
endpoint?: string
|
|
26
|
+
/** 调试直连: 直接给接入 JWT(配合 endpoint) */
|
|
27
|
+
accessToken?: string
|
|
28
|
+
/**
|
|
29
|
+
* 【推荐】服务发现: 长期服务令牌。配合 serviceAuthority 使用。
|
|
30
|
+
* SDK 会带 `Authorization: Bearer <serviceToken>` POST 到 serviceAuthority,
|
|
31
|
+
* 换取 { endpoint, accessToken(短TTL RS256), expiresIn }, 用 accessToken 连网关(?access=)。
|
|
32
|
+
*/
|
|
33
|
+
serviceToken?: string
|
|
34
|
+
/**
|
|
35
|
+
* 【推荐】服务发现端点(权威 URL)。给定时优先走服务发现流程(而非 token 直连/发证)。
|
|
36
|
+
*/
|
|
37
|
+
serviceAuthority?: string
|
|
38
|
+
/** accessToken 过期前提前刷新的秒数。默认 30 */
|
|
39
|
+
refreshSkewSec?: number
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export type EventHandler = (payload: Record<string, any>) => void
|
|
43
|
+
|
|
44
|
+
interface Pending {
|
|
45
|
+
resolve: (v: any) => void
|
|
46
|
+
reject: (e: Error) => void
|
|
47
|
+
timer: ReturnType<typeof setTimeout>
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export class ChatbotyClient {
|
|
51
|
+
private readonly opts: Required<Pick<ChatbotyClientOptions, 'token' | 'atlasUrl' | 'timeoutMs' | 'refreshSkewSec'>> &
|
|
52
|
+
ChatbotyClientOptions
|
|
53
|
+
private ws?: WebSocket
|
|
54
|
+
private accountId?: string
|
|
55
|
+
private reqId = 1
|
|
56
|
+
private readonly pending = new Map<number, Pending>()
|
|
57
|
+
private readonly handlers = new Map<string, Set<EventHandler>>()
|
|
58
|
+
private readyResolve?: () => void
|
|
59
|
+
// —— 服务发现(discovery)模式状态 ——
|
|
60
|
+
private readonly discoveryMode: boolean
|
|
61
|
+
private stopped = false
|
|
62
|
+
private autoReconnect = false
|
|
63
|
+
private currentAccessToken?: string
|
|
64
|
+
private currentEndpoint?: string
|
|
65
|
+
private refreshTimer?: ReturnType<typeof setTimeout>
|
|
66
|
+
private reconnectTimer?: ReturnType<typeof setTimeout>
|
|
67
|
+
private reconnectAttempt = 0
|
|
68
|
+
|
|
69
|
+
constructor (options: ChatbotyClientOptions) {
|
|
70
|
+
const hasDiscovery = !!(options && options.serviceToken && options.serviceAuthority)
|
|
71
|
+
const hasDirect = !!(options && (options.token || (options.endpoint && options.accessToken)))
|
|
72
|
+
if (!hasDiscovery && !hasDirect) {
|
|
73
|
+
throw new Error('ChatbotyClient: 需提供 { serviceToken, serviceAuthority } 或 { token, endpoint }')
|
|
74
|
+
}
|
|
75
|
+
this.discoveryMode = hasDiscovery
|
|
76
|
+
this.opts = {
|
|
77
|
+
atlasUrl: 'http://127.0.0.1:8080',
|
|
78
|
+
timeoutMs: 25000,
|
|
79
|
+
refreshSkewSec: 30,
|
|
80
|
+
token: '',
|
|
81
|
+
...options,
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** 该接入授权的探针实例 id(发证后可知)。 */
|
|
86
|
+
get account (): string | undefined { return this.accountId }
|
|
87
|
+
|
|
88
|
+
/** 服务发现 + 连接网关。resolve 于网关 gateway-ready。 */
|
|
89
|
+
async start (): Promise<void> {
|
|
90
|
+
this.stopped = false
|
|
91
|
+
// 【推荐】服务发现流程: serviceToken/serviceAuthority → 短TTL accessToken → 连网关(?access=) + 到期前刷新。
|
|
92
|
+
if (this.discoveryMode) {
|
|
93
|
+
const d = await this.discover()
|
|
94
|
+
this.applyDiscovery(d)
|
|
95
|
+
await this.connect(d.endpoint, d.accessToken, 'access')
|
|
96
|
+
return
|
|
97
|
+
}
|
|
98
|
+
// 直连/发证 fallback: 保持既有 `?token=<accessToken>` 行为不变。
|
|
99
|
+
let endpoint = this.opts.endpoint
|
|
100
|
+
let accessToken = this.opts.accessToken
|
|
101
|
+
if (!endpoint || !accessToken) {
|
|
102
|
+
const g = await this.grant()
|
|
103
|
+
endpoint = g.endpoint
|
|
104
|
+
accessToken = g.accessToken
|
|
105
|
+
this.accountId = g.accountId
|
|
106
|
+
}
|
|
107
|
+
await this.connect(endpoint!, accessToken!)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* 服务发现: POST serviceAuthority(带 Bearer serviceToken)换取接入凭据。
|
|
112
|
+
* 期望响应 { endpoint, accessToken, expiresIn }(可选 accountId/seatId)。
|
|
113
|
+
*/
|
|
114
|
+
private async discover (): Promise<{ endpoint: string, accessToken: string, expiresIn: number, accountId?: string }> {
|
|
115
|
+
const url = this.opts.serviceAuthority!
|
|
116
|
+
const httpFetch: (u: string, init?: any) => Promise<any> = (globalThis as any).fetch
|
|
117
|
+
if (!httpFetch) throw new Error('运行环境无 fetch(需 Node>=18)')
|
|
118
|
+
const resp = await httpFetch(url, {
|
|
119
|
+
method: 'POST',
|
|
120
|
+
headers: {
|
|
121
|
+
'content-type': 'application/json',
|
|
122
|
+
authorization: `Bearer ${this.opts.serviceToken ?? ''}`,
|
|
123
|
+
},
|
|
124
|
+
body: JSON.stringify({ token: this.opts.serviceToken }),
|
|
125
|
+
})
|
|
126
|
+
if (!resp.ok) {
|
|
127
|
+
const txt = await resp.text().catch(() => '')
|
|
128
|
+
throw new Error(`服务发现失败 ${resp.status}: ${txt.slice(0, 200)}`)
|
|
129
|
+
}
|
|
130
|
+
const j: any = await resp.json()
|
|
131
|
+
if (!j.endpoint || !j.accessToken) throw new Error('服务发现响应缺少 endpoint/accessToken')
|
|
132
|
+
const expiresIn = Number(j.expiresIn) > 0 ? Number(j.expiresIn) : 300
|
|
133
|
+
return { endpoint: j.endpoint, accessToken: j.accessToken, expiresIn, accountId: j.accountId ?? j.seatId }
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** 应用一次发现结果: 缓存 endpoint/accessToken, 并安排到期前刷新。 */
|
|
137
|
+
private applyDiscovery (d: { endpoint: string, accessToken: string, expiresIn: number, accountId?: string }): void {
|
|
138
|
+
this.currentEndpoint = d.endpoint
|
|
139
|
+
this.currentAccessToken = d.accessToken
|
|
140
|
+
if (d.accountId) this.accountId = d.accountId
|
|
141
|
+
this.scheduleRefresh(d.expiresIn)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** 在 accessToken 过期前(提前 refreshSkewSec)重新发现, 刷新缓存的 accessToken。 */
|
|
145
|
+
private scheduleRefresh (expiresIn: number): void {
|
|
146
|
+
if (this.refreshTimer) clearTimeout(this.refreshTimer)
|
|
147
|
+
if (this.stopped) return
|
|
148
|
+
const delayMs = Math.max(1000, (expiresIn - this.opts.refreshSkewSec) * 1000)
|
|
149
|
+
this.refreshTimer = setTimeout(() => { void this.refresh(0) }, delayMs)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** 刷新 accessToken; 失败按指数退避重试(封顶 30s)。刷新后的 token 供下次(重)连使用。 */
|
|
153
|
+
private async refresh (attempt: number): Promise<void> {
|
|
154
|
+
if (this.stopped) return
|
|
155
|
+
try {
|
|
156
|
+
const d = await this.discover()
|
|
157
|
+
this.applyDiscovery(d) // 内部会重新 scheduleRefresh
|
|
158
|
+
} catch {
|
|
159
|
+
if (this.stopped) return
|
|
160
|
+
const backoff = Math.min(30000, 1000 * 2 ** attempt)
|
|
161
|
+
if (this.refreshTimer) clearTimeout(this.refreshTimer)
|
|
162
|
+
this.refreshTimer = setTimeout(() => { void this.refresh(attempt + 1) }, backoff)
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** discovery 模式下断线自愈: 重新发现取新 accessToken 后重连; 失败指数退避。 */
|
|
167
|
+
private scheduleReconnect (): void {
|
|
168
|
+
if (this.stopped || this.reconnectTimer) return
|
|
169
|
+
const backoff = Math.min(30000, 1000 * 2 ** this.reconnectAttempt)
|
|
170
|
+
this.reconnectTimer = setTimeout(() => {
|
|
171
|
+
this.reconnectTimer = undefined
|
|
172
|
+
void this.reconnect()
|
|
173
|
+
}, backoff)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
private async reconnect (): Promise<void> {
|
|
177
|
+
if (this.stopped) return
|
|
178
|
+
try {
|
|
179
|
+
// 优先用刷新循环维护的最新 accessToken(“on reconnect use the fresh one”);
|
|
180
|
+
// 无缓存(如刚被失败清空)则强制重新发现。
|
|
181
|
+
let endpoint = this.currentEndpoint
|
|
182
|
+
let accessToken = this.currentAccessToken
|
|
183
|
+
if (!endpoint || !accessToken) {
|
|
184
|
+
const d = await this.discover()
|
|
185
|
+
this.applyDiscovery(d)
|
|
186
|
+
endpoint = d.endpoint
|
|
187
|
+
accessToken = d.accessToken
|
|
188
|
+
}
|
|
189
|
+
await this.connect(endpoint, accessToken, 'access')
|
|
190
|
+
this.reconnectAttempt = 0
|
|
191
|
+
} catch {
|
|
192
|
+
if (this.stopped) return
|
|
193
|
+
this.reconnectAttempt++
|
|
194
|
+
// 缓存 token 可能已失效, 清掉 → 下次重连强制重新发现。
|
|
195
|
+
this.currentAccessToken = undefined
|
|
196
|
+
this.currentEndpoint = undefined
|
|
197
|
+
this.scheduleReconnect()
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** 用订阅 token 向售卖端换接入 JWT(发证/服务发现)。 */
|
|
202
|
+
private async grant (): Promise<{ endpoint: string, accessToken: string, accountId: string }> {
|
|
203
|
+
const url = this.opts.atlasUrl.replace(/\/$/, '') + '/api/access/grant'
|
|
204
|
+
const httpFetch: (u: string, init?: any) => Promise<any> = (globalThis as any).fetch
|
|
205
|
+
if (!httpFetch) throw new Error('运行环境无 fetch(需 Node>=18)')
|
|
206
|
+
const resp = await httpFetch(url, {
|
|
207
|
+
method: 'POST',
|
|
208
|
+
headers: { 'content-type': 'application/json' },
|
|
209
|
+
body: JSON.stringify({ token: this.opts.token }),
|
|
210
|
+
})
|
|
211
|
+
if (!resp.ok) {
|
|
212
|
+
const txt = await resp.text().catch(() => '')
|
|
213
|
+
throw new Error(`发证失败 ${resp.status}: ${txt.slice(0, 200)}`)
|
|
214
|
+
}
|
|
215
|
+
const j: any = await resp.json()
|
|
216
|
+
if (!j.endpoint || !j.accessToken) throw new Error('发证响应缺少 endpoint/accessToken')
|
|
217
|
+
return { endpoint: j.endpoint, accessToken: j.accessToken, accountId: j.accountId }
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* 连接网关。queryKey='token' 为直连/发证 fallback(既有行为不变);
|
|
222
|
+
* queryKey='access' 为服务发现流程(网关验 RS256 accessToken → seatId)。
|
|
223
|
+
*/
|
|
224
|
+
private connect (endpoint: string, accessToken: string, queryKey: 'token' | 'access' = 'token'): Promise<void> {
|
|
225
|
+
const sep = endpoint.includes('/client') ? '' : '/client'
|
|
226
|
+
const wsUrl = `${endpoint}${sep}?${queryKey}=${encodeURIComponent(accessToken)}`
|
|
227
|
+
return new Promise((resolve, reject) => {
|
|
228
|
+
const ws = new WebSocket(wsUrl)
|
|
229
|
+
this.ws = ws
|
|
230
|
+
this.readyResolve = resolve
|
|
231
|
+
const onErr = (e: Error) => reject(new Error('连接网关失败: ' + e.message))
|
|
232
|
+
ws.once('error', onErr)
|
|
233
|
+
ws.on('open', () => {
|
|
234
|
+
ws.removeListener('error', onErr)
|
|
235
|
+
// 首次成功连上后才开启断线自愈, 避免首连失败时把错误吞进后台重连。
|
|
236
|
+
if (queryKey === 'access') this.autoReconnect = true
|
|
237
|
+
})
|
|
238
|
+
ws.on('message', (raw: WebSocket.RawData) => this.onFrame(raw))
|
|
239
|
+
ws.on('close', () => {
|
|
240
|
+
if (this.ws === ws) this.ws = undefined
|
|
241
|
+
this.emit('disconnected', {})
|
|
242
|
+
if (this.autoReconnect && !this.stopped) this.scheduleReconnect()
|
|
243
|
+
})
|
|
244
|
+
})
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
private onFrame (raw: WebSocket.RawData): void {
|
|
248
|
+
let m: any
|
|
249
|
+
try { m = JSON.parse(raw.toString()) } catch { return }
|
|
250
|
+
if (typeof m.id === 'number' && this.pending.has(m.id)) { // 命令回包
|
|
251
|
+
const p = this.pending.get(m.id)!
|
|
252
|
+
this.pending.delete(m.id)
|
|
253
|
+
clearTimeout(p.timer)
|
|
254
|
+
if (m.ok) p.resolve(m.result)
|
|
255
|
+
else p.reject(new Error(m.error || 'command failed'))
|
|
256
|
+
return
|
|
257
|
+
}
|
|
258
|
+
if (m.ev) { // 事件
|
|
259
|
+
if (m.ev === 'gateway-ready') {
|
|
260
|
+
if (!this.accountId && Array.isArray(m.accounts) && m.accounts.length) this.accountId = m.accounts[0]
|
|
261
|
+
if (this.readyResolve) { this.readyResolve(); this.readyResolve = undefined }
|
|
262
|
+
}
|
|
263
|
+
this.emit(m.ev, m)
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** 订阅事件: message / room-join / room-leave / room-topic / friendship / login / logout / disconnected ... */
|
|
268
|
+
on (event: string, handler: EventHandler): this {
|
|
269
|
+
if (!this.handlers.has(event)) this.handlers.set(event, new Set())
|
|
270
|
+
this.handlers.get(event)!.add(handler)
|
|
271
|
+
return this
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
off (event: string, handler: EventHandler): this {
|
|
275
|
+
this.handlers.get(event)?.delete(handler)
|
|
276
|
+
return this
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
private emit (event: string, payload: Record<string, any>): void {
|
|
280
|
+
this.handlers.get(event)?.forEach((h) => { try { h(payload) } catch { /* 隔离回调异常 */ } })
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** 通用命令: 发到网关 → 路由到本接入授权的探针。 */
|
|
284
|
+
command (cmd: string, args: Record<string, any> = {}): Promise<any> {
|
|
285
|
+
const ws = this.ws
|
|
286
|
+
if (!ws || ws.readyState !== WebSocket.OPEN) return Promise.reject(new Error('未连接'))
|
|
287
|
+
const id = this.reqId++
|
|
288
|
+
return new Promise((resolve, reject) => {
|
|
289
|
+
const timer = setTimeout(() => {
|
|
290
|
+
if (this.pending.has(id)) { this.pending.delete(id); reject(new Error('命令超时: ' + cmd)) }
|
|
291
|
+
}, this.opts.timeoutMs)
|
|
292
|
+
this.pending.set(id, { resolve, reject, timer })
|
|
293
|
+
ws.send(JSON.stringify({ id, accountId: this.accountId, cmd, ...args }))
|
|
294
|
+
})
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// —— 便捷方法(P2 框架前的最小集; 完整 Contact/Room/Message 对象模型见 P2) ——
|
|
298
|
+
roomList (): Promise<any> { return this.command('roomList') }
|
|
299
|
+
contactList (limit = 0): Promise<any> { return this.command('contactList', { limit }) }
|
|
300
|
+
say (conversationId: string, text: string): Promise<any> { return this.command('sendText', { conversationId, text }) }
|
|
301
|
+
|
|
302
|
+
/** 断开。停止刷新/重连定时器。 */
|
|
303
|
+
stop (): void {
|
|
304
|
+
this.stopped = true
|
|
305
|
+
this.autoReconnect = false
|
|
306
|
+
if (this.refreshTimer) { clearTimeout(this.refreshTimer); this.refreshTimer = undefined }
|
|
307
|
+
if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); this.reconnectTimer = undefined }
|
|
308
|
+
try { this.ws?.close() } catch { /* noop */ }
|
|
309
|
+
}
|
|
310
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { BotApi, ContactPayload, Sayable } from '../types.js'
|
|
2
|
+
import { ContactType } from '../types.js'
|
|
3
|
+
|
|
4
|
+
/** 联系人实体(含外部客户)。 */
|
|
5
|
+
export class Contact {
|
|
6
|
+
constructor (private readonly bot: BotApi, readonly payload: ContactPayload) {}
|
|
7
|
+
|
|
8
|
+
id (): string { return this.payload.id }
|
|
9
|
+
name (): string { return this.payload.name ?? '' }
|
|
10
|
+
alias (): string | undefined { return this.payload.alias }
|
|
11
|
+
avatar (): string | undefined { return this.payload.avatar }
|
|
12
|
+
phone (): string | undefined { return this.payload.phone }
|
|
13
|
+
title (): string | undefined { return this.payload.title }
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* 联系人类别。企微通讯录成员/外部客户都是真人 → 默认 {@link ContactType.Individual}。
|
|
17
|
+
* (企微不存在微信式"公众号/服务号", 且探针未采集"应用号/机器人"标志, 故不臆造 Official。)
|
|
18
|
+
*/
|
|
19
|
+
type (): ContactType { return this.payload.type ?? ContactType.Individual }
|
|
20
|
+
|
|
21
|
+
/** 单聊会话 id(发消息目标)。 */
|
|
22
|
+
conversationId (): string | undefined { return this.payload.conversationId }
|
|
23
|
+
|
|
24
|
+
/** 给该联系人发消息(单聊)。 */
|
|
25
|
+
async say (sayable: Sayable): Promise<any> {
|
|
26
|
+
const conv = this.payload.conversationId
|
|
27
|
+
if (!conv) throw new Error(`Contact ${this.id()} 无单聊会话 id, 无法发送`)
|
|
28
|
+
return this.bot.sayTo(conv, sayable)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
toString (): string { return `Contact<${this.name() || this.id()}>` }
|
|
32
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { BotApi, FriendshipPayload, FriendshipKind } from '../types.js'
|
|
2
|
+
import { Contact } from './contact.js'
|
|
3
|
+
|
|
4
|
+
/** 好友关系事件实体。 */
|
|
5
|
+
export class Friendship {
|
|
6
|
+
constructor (private readonly bot: BotApi, readonly payload: FriendshipPayload) {}
|
|
7
|
+
|
|
8
|
+
kind (): FriendshipKind { return this.payload.kind }
|
|
9
|
+
hello (): string | undefined { return this.payload.hello }
|
|
10
|
+
|
|
11
|
+
contact (): Contact {
|
|
12
|
+
return new Contact(this.bot, this.payload.contact ?? { id: this.payload.contactId })
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
toString (): string { return `Friendship<${this.kind()}:${this.payload.contactId}>` }
|
|
16
|
+
}
|