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.
Files changed (76) hide show
  1. package/LICENSE +162 -0
  2. package/README.md +119 -0
  3. package/dist/cjs/package.json +3 -0
  4. package/dist/cjs/src/chatboty.d.ts +93 -0
  5. package/dist/cjs/src/chatboty.d.ts.map +1 -0
  6. package/dist/cjs/src/chatboty.js +204 -0
  7. package/dist/cjs/src/chatboty.js.map +1 -0
  8. package/dist/cjs/src/client.d.ts +84 -0
  9. package/dist/cjs/src/client.d.ts.map +1 -0
  10. package/dist/cjs/src/client.js +298 -0
  11. package/dist/cjs/src/client.js.map +1 -0
  12. package/dist/cjs/src/entity/contact.d.ts +25 -0
  13. package/dist/cjs/src/entity/contact.d.ts.map +1 -0
  14. package/dist/cjs/src/entity/contact.js +34 -0
  15. package/dist/cjs/src/entity/contact.js.map +1 -0
  16. package/dist/cjs/src/entity/friendship.d.ts +13 -0
  17. package/dist/cjs/src/entity/friendship.d.ts.map +1 -0
  18. package/dist/cjs/src/entity/friendship.js +19 -0
  19. package/dist/cjs/src/entity/friendship.js.map +1 -0
  20. package/dist/cjs/src/entity/message.d.ts +61 -0
  21. package/dist/cjs/src/entity/message.d.ts.map +1 -0
  22. package/dist/cjs/src/entity/message.js +150 -0
  23. package/dist/cjs/src/entity/message.js.map +1 -0
  24. package/dist/cjs/src/entity/room.d.ts +21 -0
  25. package/dist/cjs/src/entity/room.d.ts.map +1 -0
  26. package/dist/cjs/src/entity/room.js +43 -0
  27. package/dist/cjs/src/entity/room.js.map +1 -0
  28. package/dist/cjs/src/mod.d.ts +17 -0
  29. package/dist/cjs/src/mod.d.ts.map +1 -0
  30. package/dist/cjs/src/mod.js +41 -0
  31. package/dist/cjs/src/mod.js.map +1 -0
  32. package/dist/cjs/src/types.d.ts +122 -0
  33. package/dist/cjs/src/types.d.ts.map +1 -0
  34. package/dist/cjs/src/types.js +55 -0
  35. package/dist/cjs/src/types.js.map +1 -0
  36. package/dist/esm/src/chatboty.d.ts +93 -0
  37. package/dist/esm/src/chatboty.d.ts.map +1 -0
  38. package/dist/esm/src/chatboty.js +200 -0
  39. package/dist/esm/src/chatboty.js.map +1 -0
  40. package/dist/esm/src/client.d.ts +84 -0
  41. package/dist/esm/src/client.d.ts.map +1 -0
  42. package/dist/esm/src/client.js +291 -0
  43. package/dist/esm/src/client.js.map +1 -0
  44. package/dist/esm/src/entity/contact.d.ts +25 -0
  45. package/dist/esm/src/entity/contact.d.ts.map +1 -0
  46. package/dist/esm/src/entity/contact.js +30 -0
  47. package/dist/esm/src/entity/contact.js.map +1 -0
  48. package/dist/esm/src/entity/friendship.d.ts +13 -0
  49. package/dist/esm/src/entity/friendship.d.ts.map +1 -0
  50. package/dist/esm/src/entity/friendship.js +15 -0
  51. package/dist/esm/src/entity/friendship.js.map +1 -0
  52. package/dist/esm/src/entity/message.d.ts +61 -0
  53. package/dist/esm/src/entity/message.d.ts.map +1 -0
  54. package/dist/esm/src/entity/message.js +146 -0
  55. package/dist/esm/src/entity/message.js.map +1 -0
  56. package/dist/esm/src/entity/room.d.ts +21 -0
  57. package/dist/esm/src/entity/room.d.ts.map +1 -0
  58. package/dist/esm/src/entity/room.js +39 -0
  59. package/dist/esm/src/entity/room.js.map +1 -0
  60. package/dist/esm/src/mod.d.ts +17 -0
  61. package/dist/esm/src/mod.d.ts.map +1 -0
  62. package/dist/esm/src/mod.js +17 -0
  63. package/dist/esm/src/mod.js.map +1 -0
  64. package/dist/esm/src/types.d.ts +122 -0
  65. package/dist/esm/src/types.d.ts.map +1 -0
  66. package/dist/esm/src/types.js +50 -0
  67. package/dist/esm/src/types.js.map +1 -0
  68. package/package.json +52 -0
  69. package/src/chatboty.ts +228 -0
  70. package/src/client.ts +310 -0
  71. package/src/entity/contact.ts +32 -0
  72. package/src/entity/friendship.ts +16 -0
  73. package/src/entity/message.ts +147 -0
  74. package/src/entity/room.ts +42 -0
  75. package/src/mod.ts +19 -0
  76. 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
+ }
@@ -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
+ }