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
@@ -0,0 +1,147 @@
1
+ import type { BotApi, MessagePayload, Sayable, FileBox } from '../types.js'
2
+ import { MessageType } from '../types.js'
3
+ import { Contact } from './contact.js'
4
+ import { Room } from './room.js'
5
+
6
+ /** 消息实体。 */
7
+ export class Message {
8
+ constructor (private readonly bot: BotApi, readonly payload: MessagePayload) {}
9
+
10
+ id (): string | undefined { return this.payload.id }
11
+ type (): MessageType { return this.payload.type }
12
+ text (): string { return this.payload.text ?? '' }
13
+ self (): boolean { return !!this.payload.self }
14
+ timestamp (): number | undefined { return this.payload.timestamp }
15
+ file (): FileBox | null | undefined { return this.payload.file }
16
+ miniProgram (): Record<string, any> | null | undefined { return this.payload.miniProgram }
17
+ urlLink (): Record<string, any> | null | undefined { return this.payload.urlLink }
18
+
19
+ /** 发送者(完整 Contact; 群消息识别客户用)。 */
20
+ talker (): Contact | undefined {
21
+ if (this.payload.talker) return new Contact(this.bot, this.payload.talker)
22
+ if (this.payload.talkerId) return new Contact(this.bot, { id: this.payload.talkerId })
23
+ return undefined
24
+ }
25
+
26
+ /** 所在群(单聊为 undefined)。 */
27
+ room (): Room | undefined {
28
+ return this.payload.roomId ? new Room(this.bot, { id: this.payload.roomId }) : undefined
29
+ }
30
+
31
+ /** 回复到原会话(群消息回群, 单聊回该人)。 */
32
+ async say (sayable: Sayable): Promise<any> {
33
+ const target = this.payload.roomId || this.payload.conversationId
34
+ return this.bot.sayTo(target, sayable)
35
+ }
36
+
37
+ /* ---------------- @ 提及 ---------------- */
38
+
39
+ /**
40
+ * 从消息正文解析出的 @ 提及显示名(不含 "@所有人"/"@all")。
41
+ * 说明: 企微群 @ 消息正文形如 `@张三 内容`, 名字后接普通空格或 U+2005(four-per-em space)分隔。
42
+ * 这是"从消息 payload 解析"的诚实实现——只用真实的正文文本; 结构化的被@ vid 需进一步逆向(见 RE 计划)。
43
+ */
44
+ private mentionNames (): string[] {
45
+ const text = this.payload.text ?? ''
46
+ if (!text.includes('@')) return []
47
+ const out: string[] = []
48
+ const re = /@([^\s @]+)/g
49
+ let m: RegExpExecArray | null
50
+ while ((m = re.exec(text)) !== null) {
51
+ const n = m[1]
52
+ if (n && n !== '所有人' && n.toLowerCase() !== 'all') out.push(n)
53
+ }
54
+ return out
55
+ }
56
+
57
+ /**
58
+ * 消息中被 @ 的联系人列表(best-effort)。
59
+ * 先从正文解析出 @显示名, 再用一次 contactList 拉全量通讯录按 name/alias 就近解析成完整 Contact;
60
+ * 未在已加载通讯录中的成员回退为仅含名字的 Contact(其 id() 为空串)。
61
+ * @returns 被 @ 的 Contact 数组(无 @ 时为空数组)。
62
+ */
63
+ async mentionList (): Promise<Contact[]> {
64
+ const names = this.mentionNames()
65
+ if (names.length === 0) return []
66
+ let pool: any[] = []
67
+ try {
68
+ const r = await this.bot.command('contactList')
69
+ pool = (r && (r.contacts || r.list || (Array.isArray(r) ? r : []))) || []
70
+ } catch { /* 解析失败不致命: 退回仅名字的 Contact */ }
71
+ return names.map((nm) => {
72
+ const u = pool.find((x: any) => x && (x.name === nm || x.alias === nm || x.realName === nm))
73
+ if (u) {
74
+ return new Contact(this.bot, {
75
+ id: u.userId ?? u.id ?? '', name: u.name ?? u.realName, alias: u.alias, avatar: u.avatar,
76
+ phone: u.phone, title: u.title, corpId: u.corpId, unionId: u.unionId, conversationId: u.conversationId,
77
+ })
78
+ }
79
+ return new Contact(this.bot, { id: '', name: nm })
80
+ })
81
+ }
82
+
83
+ /**
84
+ * 本条消息是否 @ 了当前登录者(self)或 @所有人。
85
+ * 判定: 正文含 `@所有人`/`@all`(全体含自己) → true; 或含 `@{self 显示名}` → true。
86
+ * (纯本机文本判定, 不发起网络请求; 依赖 bot 已知 self 名。)
87
+ */
88
+ mentionSelf (): boolean {
89
+ const text = this.payload.text ?? ''
90
+ if (!text.includes('@')) return false
91
+ if (/@所有人|@all\b/i.test(text)) return true
92
+ const selfName = this.bot.selfContact?.()?.name
93
+ return !!selfName && this.mentionNames().includes(selfName)
94
+ }
95
+
96
+ /* ---------------- 媒体下载 ---------------- */
97
+
98
+ private toFileBoxFromResult (r: any): FileBox | null {
99
+ if (!r) return null
100
+ const dataUrl = r.dataUrl || r.base64
101
+ if (!dataUrl) return null
102
+ return { dataUrl, mime: r.mime, name: r.name, size: r.size }
103
+ }
104
+
105
+ /**
106
+ * 取本条消息的媒体/文件字节(图片/文件/音视频), 返回 {@link FileBox}(含 base64 data URL)。
107
+ * - 收到的图片/文件事件通常已附带 file(探针后台已下载解密) → 直接返回。
108
+ * - 否则按类型冷调探针下载: 图片走 downloadImage, 文件/音视频走 downloadFile(按 msgid+会话+文件名定位缓存)。
109
+ * @returns FileBox(dataUrl/mime/name/size); 非媒体消息或下载失败返回 null。
110
+ */
111
+ async toFileBox (): Promise<FileBox | null> {
112
+ if (this.payload.file) return this.payload.file
113
+ const msgid = this.payload.id
114
+ if (!msgid) return null
115
+ const t = this.payload.type
116
+ if (t === MessageType.Image) {
117
+ const r = await this.bot.command('downloadImage', { msgid })
118
+ return this.toFileBoxFromResult(r)
119
+ }
120
+ if (t === MessageType.File || t === MessageType.Video || t === MessageType.Audio) {
121
+ const r = await this.bot.command('downloadFile', {
122
+ msgid,
123
+ conversationId: this.payload.conversationId,
124
+ timestamp: this.payload.timestamp,
125
+ name: this.payload.text,
126
+ })
127
+ return this.toFileBoxFromResult(r)
128
+ }
129
+ return null
130
+ }
131
+
132
+ /** {@link toFileBox} 的别名(下载本条消息的媒体字节)。 */
133
+ async download (): Promise<FileBox | null> { return this.toFileBox() }
134
+
135
+ /* ---------------- 转发(RE 待定) ---------------- */
136
+
137
+ /**
138
+ * 转发本条消息到另一会话(群/单聊 conversationId, 或 Room/Contact)。
139
+ * ⚠️ 未实现: 企微转发需逆向定位并冷调"转发消息"服务(见 puppet-wework 交付说明的 RE 计划)。
140
+ * 当前会抛错, 以免静默失败。
141
+ */
142
+ async forward (_to: string | Room | Contact): Promise<never> {
143
+ throw new Error('Message.forward 未实现: 需逆向定位企微转发消息服务(ForwardMessage), 见 RE 计划')
144
+ }
145
+
146
+ toString (): string { return `Message<${MessageType[this.type()]}:${this.text().slice(0, 30)}>` }
147
+ }
@@ -0,0 +1,42 @@
1
+ import type { BotApi, RoomPayload, Sayable } from '../types.js'
2
+ import { Contact } from './contact.js'
3
+
4
+ /** 群实体。 */
5
+ export class Room {
6
+ constructor (private readonly bot: BotApi, readonly payload: RoomPayload) {}
7
+
8
+ id (): string { return this.payload.id }
9
+ topic (): string | undefined { return this.payload.topic }
10
+ ownerId (): string | undefined { return this.payload.ownerId }
11
+
12
+ /** 群发消息。 */
13
+ async say (sayable: Sayable): Promise<any> {
14
+ return this.bot.sayTo(this.payload.id, sayable)
15
+ }
16
+
17
+ /** 改群名(冷改, 内外群通用)。 */
18
+ async rename (topic: string): Promise<any> {
19
+ return this.bot.command('roomTopic', { conversationId: this.payload.id, topic })
20
+ }
21
+
22
+ /** 群成员(完整 Contact 对象; 受探针成员枚举局限)。 */
23
+ async memberAll (): Promise<Contact[]> {
24
+ const r = await this.bot.command('roomMembers', { conversationId: this.payload.id })
25
+ const list = (r && (r.memberList || [])) as any[]
26
+ return list.map((u) => new Contact(this.bot, {
27
+ id: u.userId, name: u.name, alias: u.alias, avatar: u.avatar,
28
+ phone: u.phone, title: u.title, corpId: u.corpId, unionId: u.unionId,
29
+ conversationId: u.conversationId,
30
+ }))
31
+ }
32
+
33
+ /** 群主(若可解析)。 */
34
+ async owner (): Promise<Contact | undefined> {
35
+ const r = await this.bot.command('roomMembers', { conversationId: this.payload.id })
36
+ const u = r && r.ownerUser
37
+ if (!u || !u.userId) return undefined
38
+ return new Contact(this.bot, { id: u.userId, name: u.name, alias: u.alias, avatar: u.avatar, conversationId: u.conversationId })
39
+ }
40
+
41
+ toString (): string { return `Room<${this.topic() || this.id()}>` }
42
+ }
package/src/mod.ts ADDED
@@ -0,0 +1,19 @@
1
+ /**
2
+ * chatboty 公共入口。自研 IM 自动化框架(企业微信), 零外部框架依赖。
3
+ *
4
+ * import { Chatboty } from 'chatboty'
5
+ * const bot = new Chatboty({ token: 'cbt_...' })
6
+ * bot.on('message', m => { if (!m.self()) m.say('收到: ' + m.text()) })
7
+ * await bot.start()
8
+ */
9
+ export { Chatboty, type ChatbotyOptions } from './chatboty.js'
10
+ export { ChatbotyClient, type ChatbotyClientOptions, type EventHandler } from './client.js'
11
+
12
+ export { Message } from './entity/message.js'
13
+ export { Contact } from './entity/contact.js'
14
+ export { Room } from './entity/room.js'
15
+ export { Friendship } from './entity/friendship.js'
16
+
17
+ export * from './types.js'
18
+
19
+ export { Chatboty as default } from './chatboty.js'
package/src/types.ts ADDED
@@ -0,0 +1,142 @@
1
+ /**
2
+ * chatboty 自研类型(零外部框架依赖)。这些 payload 是实体类的数据底座,
3
+ * 由网关下发的 wire 事件/命令结果映射而来。
4
+ */
5
+
6
+ /** 消息类型。 */
7
+ export enum MessageType {
8
+ Unknown = 0,
9
+ Text,
10
+ Image,
11
+ Audio,
12
+ Video,
13
+ File,
14
+ Url, // 链接卡片
15
+ MiniProgram,
16
+ System, // 系统/通知(群事件等)
17
+ }
18
+
19
+ /**
20
+ * 联系人类别。企微现实: 通讯录里都是"真人"(个人/同事/外部客户), 没有微信那种"公众号/服务号"构造,
21
+ * 故探针默认给 Individual。Official/Corporation 预留给未来能可靠区分"应用/机器人/企业主体"时使用。
22
+ */
23
+ export enum ContactType {
24
+ Unknown = 0,
25
+ Individual, // 真人(默认; 企微通讯录成员/外部客户)
26
+ Official, // 应用/机器人号(企微暂无可靠信号, 预留)
27
+ Corporation, // 企业主体(预留)
28
+ }
29
+
30
+ /** 联系人(含外部客户)。 */
31
+ export interface ContactPayload {
32
+ id: string // vid
33
+ name?: string
34
+ alias?: string // 备注
35
+ avatar?: string
36
+ phone?: string
37
+ title?: string // 职务/部门
38
+ corpId?: string
39
+ unionId?: string
40
+ conversationId?: string // 单聊会话 id(S:..), 发消息目标
41
+ type?: ContactType // 类别(探针未标注时由 Contact.type() 兜底 Individual)
42
+ // 注: 企微企业通讯录未采集"性别"字段(探针 dump 的 User 结构无此项), 故 SDK 不暴露 gender()。
43
+ }
44
+
45
+ /** Contact.find/findAll 查询条件: 关键词字符串, 或按字段精确匹配。 */
46
+ export type ContactQueryFilter =
47
+ | string
48
+ | { name?: string, alias?: string, id?: string }
49
+
50
+ /** 群。 */
51
+ export interface RoomPayload {
52
+ id: string // R:..
53
+ topic?: string
54
+ ownerId?: string
55
+ memberIdList?: string[]
56
+ }
57
+
58
+ /** 媒体附件(图片/文件)。 */
59
+ export interface FileBox {
60
+ dataUrl?: string
61
+ mime?: string
62
+ name?: string
63
+ size?: number
64
+ }
65
+
66
+ /** 消息。 */
67
+ export interface MessagePayload {
68
+ id?: string
69
+ type: MessageType
70
+ text?: string
71
+ talkerId?: string
72
+ roomId?: string // 群消息所在群(单聊为空)
73
+ conversationId: string // 消息会话(R: 群 / S: 单聊)
74
+ timestamp?: number
75
+ self?: boolean
76
+ talker?: ContactPayload // 完整发送者(已对象化)
77
+ file?: FileBox | null
78
+ miniProgram?: Record<string, any> | null
79
+ urlLink?: Record<string, any> | null
80
+ }
81
+
82
+ /** 好友关系事件。 */
83
+ export type FriendshipKind = 'receive' | 'confirm' | 'add' | 'delete'
84
+ export interface FriendshipPayload {
85
+ contactId: string
86
+ contact?: ContactPayload
87
+ kind: FriendshipKind
88
+ hello?: string
89
+ }
90
+
91
+ /** 群事件(成员变动/改名) payload。 */
92
+ export interface RoomMemberEventPayload {
93
+ roomId: string
94
+ idList: string[] // 进/退群成员 vid
95
+ contactList?: ContactPayload[] // 完整成员对象
96
+ operatorId?: string | null // 操作者(系统消息常缺)
97
+ timestamp?: number
98
+ }
99
+ export interface RoomTopicEventPayload {
100
+ roomId: string
101
+ topic?: string
102
+ oldTopic?: string
103
+ changerId?: string | null
104
+ timestamp?: number
105
+ }
106
+
107
+ /** 可发送内容。文本=字符串; 媒体=带类型的对象(路径/URL/base64 由探针侧解析)。 */
108
+ export type Sayable =
109
+ | string
110
+ | { image: string } // 图片(URL/本地路径/base64)
111
+ | { file: string } // 文件
112
+ | { url: string, title?: string, description?: string, thumbUrl?: string } // 链接卡片
113
+ | { miniProgram: Record<string, any> } // 小程序卡片
114
+
115
+ /** 实体类回引 bot 的最小接口(避免循环依赖)。Chatboty 主类实现之。 */
116
+ export interface BotApi {
117
+ command (cmd: string, args?: Record<string, any>): Promise<any>
118
+ sayTo (conversationId: string, sayable: Sayable): Promise<any>
119
+ /** 当前登录者(self)payload(供 Message.mentionSelf 等做本机判定); 未就绪时 undefined。 */
120
+ selfContact? (): ContactPayload | undefined
121
+ }
122
+
123
+ /** 会话 id 类型判定(R: 群 / S: 单聊 / 其它)。 */
124
+ export function isRoomId (conversationId?: string): boolean {
125
+ return !!conversationId && conversationId.startsWith('R:')
126
+ }
127
+
128
+ /** wire 事件里的字符串/数字消息类型 → MessageType。 */
129
+ export function toMessageType (typeStr?: string, typeRaw?: number): MessageType {
130
+ switch ((typeStr || '').toLowerCase()) {
131
+ case 'text': return MessageType.Text
132
+ case 'image': return MessageType.Image
133
+ case 'image/file': return MessageType.Image
134
+ case 'voice': case 'audio': return MessageType.Audio
135
+ case 'video': return MessageType.Video
136
+ case 'file': return MessageType.File
137
+ case 'url': return MessageType.Url
138
+ case 'miniprogram': return MessageType.MiniProgram
139
+ case 'sys': case 'system': return MessageType.System
140
+ default: return typeRaw ? MessageType.Unknown : MessageType.Unknown
141
+ }
142
+ }