@mhfire/dsh-im-bridge 0.2.0 → 0.4.2

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.
@@ -0,0 +1,228 @@
1
+ /**
2
+ * Collect workspace PNG paths from assistant Markdown and send them as
3
+ * WeCom image messages after the text stream finishes.
4
+ */
5
+
6
+ import { readFileSync, realpathSync, statSync } from 'node:fs'
7
+ import { basename, isAbsolute, relative, resolve, sep } from 'node:path'
8
+
9
+ /** WeCom image-type upload cap (bytes before encoding). */
10
+ export const MAX_PNG_BYTES = 10 * 1024 * 1024
11
+ /** WeCom mixed-image list cap; also keeps send-rate headroom after stream ticks. */
12
+ export const MAX_PNG_COUNT = 10
13
+ /** PNG signature (first 8 bytes). */
14
+ const PNG_MAGIC = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
15
+
16
+ /** One local PNG ready to upload. */
17
+ export interface CollectedPng {
18
+ /** Realpath inside the workspace. */
19
+ absPath: string
20
+ /** Basename passed to `uploadMedia` (`*.png`). */
21
+ filename: string
22
+ /** File bytes. */
23
+ buffer: Buffer
24
+ }
25
+
26
+ /** Result of scanning a reply for sendable PNGs. */
27
+ export interface CollectPngResult {
28
+ /** Deduped PNGs in first-seen order, capped at {@link MAX_PNG_COUNT}. */
29
+ images: CollectedPng[]
30
+ /** Human-readable skip reasons (path + why). */
31
+ skipped: string[]
32
+ }
33
+
34
+ /** Optional caps for tests; production uses the WeCom defaults. */
35
+ export interface CollectPngOptions {
36
+ /** Override {@link MAX_PNG_BYTES}. */
37
+ maxBytes?: number
38
+ /** Override {@link MAX_PNG_COUNT}. */
39
+ maxCount?: number
40
+ }
41
+
42
+ /** WeCom client methods used after `sendFinal`. */
43
+ export interface MediaSender {
44
+ uploadMedia(
45
+ fileBuffer: Buffer,
46
+ options: { type: string; filename: string },
47
+ ): Promise<{ media_id?: string; mediaId?: string }>
48
+ sendMediaMessage(chatid: string, mediaType: string, mediaId: string): Promise<unknown>
49
+ }
50
+
51
+ /** `![alt](url)` or `[text](url)`, capturing the destination. */
52
+ const MARKDOWN_LINK = /!?\[(?:[^\]]*?)\]\(\s*(<[^>]+>|[^\s)]+)(?:\s+(?:"[^"]*"|'[^']*'))?\s*\)/g
53
+
54
+ /**
55
+ * Pull destination URLs from Markdown images and links, in order.
56
+ * @param text - assistant reply body.
57
+ * @returns raw destinations (angle brackets already stripped).
58
+ */
59
+ export function extractMarkdownUrls(text: string): string[] {
60
+ const urls: string[] = []
61
+ for (const match of text.matchAll(MARKDOWN_LINK)) {
62
+ const raw = match[1]
63
+ if (raw === undefined) continue
64
+ const dest = raw.startsWith('<') && raw.endsWith('>') ? raw.slice(1, -1) : raw
65
+ if (dest !== '') urls.push(dest)
66
+ }
67
+ return urls
68
+ }
69
+
70
+ /**
71
+ * Chat id for `sendMediaMessage`: group `chatid`, otherwise the sender userid.
72
+ * @param frame - inbound WeCom frame.
73
+ * @param sender - userid used for 1:1 chats.
74
+ */
75
+ export function resolveChatId(
76
+ frame: { body?: { chatid?: string; chattype?: string | number } },
77
+ sender: string,
78
+ ): string {
79
+ const chattype = frame.body?.chattype
80
+ const chatid = frame.body?.chatid
81
+ const grouped = chattype === 'group' || chattype === 2 || chattype === '2'
82
+ if (grouped && chatid) return chatid
83
+ const single = chattype === 'single' || chattype === 1 || chattype === '1'
84
+ if (!single && chatid) return chatid
85
+ return sender
86
+ }
87
+
88
+ function stripQueryHash(url: string): string {
89
+ const noHash = url.split('#')[0] ?? url
90
+ return noHash.split('?')[0] ?? noHash
91
+ }
92
+
93
+ function isRemote(url: string): boolean {
94
+ return /^(?:https?:|data:|mailto:|file:)/i.test(url)
95
+ }
96
+
97
+ function isPngPath(url: string): boolean {
98
+ return /\.png$/i.test(url)
99
+ }
100
+
101
+ function containedIn(root: string, target: string): boolean {
102
+ const rel = relative(root, target)
103
+ if (rel === '') return false
104
+ if (isAbsolute(rel)) return false
105
+ if (rel === '..' || rel.startsWith(`..${sep}`)) return false
106
+ return true
107
+ }
108
+
109
+ function tryRealpath(path: string): string | undefined {
110
+ try {
111
+ return realpathSync(path)
112
+ } catch {
113
+ return undefined
114
+ }
115
+ }
116
+
117
+ /**
118
+ * Resolve Markdown destinations to workspace PNG files.
119
+ * @param text - untruncated assistant reply.
120
+ * @param workspace - Agent cwd / plugin `workspace`.
121
+ * @param options - optional size/count caps.
122
+ */
123
+ export function collectReplyPngs(
124
+ text: string,
125
+ workspace: string,
126
+ options?: CollectPngOptions,
127
+ ): CollectPngResult {
128
+ const maxBytes = options?.maxBytes ?? MAX_PNG_BYTES
129
+ const maxCount = options?.maxCount ?? MAX_PNG_COUNT
130
+ const images: CollectedPng[] = []
131
+ const skipped: string[] = []
132
+ const seen = new Set<string>()
133
+ const root = tryRealpath(workspace)
134
+ if (root === undefined) {
135
+ skipped.push(`工作区不可读: ${workspace}`)
136
+ return { images, skipped }
137
+ }
138
+
139
+ for (const raw of extractMarkdownUrls(text)) {
140
+ const url = stripQueryHash(raw.trim())
141
+ if (url === '' || isRemote(url)) continue
142
+ if (!isPngPath(url)) continue
143
+ if (images.length >= maxCount) {
144
+ skipped.push(`超过 ${maxCount} 张上限,忽略后续图片`)
145
+ break
146
+ }
147
+ const abs = resolve(root, url)
148
+ const real = tryRealpath(abs)
149
+ if (real === undefined) {
150
+ skipped.push(`文件不存在: ${url}`)
151
+ continue
152
+ }
153
+ if (!containedIn(root, real)) {
154
+ skipped.push(`越出工作区: ${url}`)
155
+ continue
156
+ }
157
+ if (seen.has(real)) continue
158
+ let size: number
159
+ try {
160
+ size = statSync(real).size
161
+ } catch {
162
+ skipped.push(`无法读取: ${url}`)
163
+ continue
164
+ }
165
+ if (size > maxBytes) {
166
+ skipped.push(`超过 ${maxBytes} 字节: ${url}`)
167
+ continue
168
+ }
169
+ let buffer: Buffer
170
+ try {
171
+ buffer = readFileSync(real)
172
+ } catch {
173
+ skipped.push(`无法读取: ${url}`)
174
+ continue
175
+ }
176
+ if (buffer.subarray(0, PNG_MAGIC.length).compare(PNG_MAGIC) !== 0) {
177
+ skipped.push(`不是 PNG: ${url}`)
178
+ continue
179
+ }
180
+ seen.add(real)
181
+ images.push({ absPath: real, filename: basename(real), buffer })
182
+ }
183
+ return { images, skipped }
184
+ }
185
+
186
+ function mediaIdOf(result: { media_id?: string; mediaId?: string }): string | undefined {
187
+ const id = result.media_id ?? result.mediaId
188
+ return id !== undefined && id !== '' ? id : undefined
189
+ }
190
+
191
+ /**
192
+ * Upload each PNG then push it as a WeCom image message.
193
+ * Failures are logged and do not abort the remaining files.
194
+ * @param ws - WeCom client.
195
+ * @param chatid - 1:1 userid or group chatid.
196
+ * @param images - files from {@link collectReplyPngs}.
197
+ * @returns counts of sent vs failed filenames.
198
+ */
199
+ export async function sendCollectedPngs(
200
+ ws: MediaSender,
201
+ chatid: string,
202
+ images: CollectedPng[],
203
+ ): Promise<{ sent: number; failed: string[] }> {
204
+ const failed: string[] = []
205
+ let sent = 0
206
+ for (const image of images) {
207
+ try {
208
+ const uploaded = await ws.uploadMedia(image.buffer, {
209
+ type: 'image',
210
+ filename: image.filename,
211
+ })
212
+ const mediaId = mediaIdOf(uploaded)
213
+ if (mediaId === undefined) {
214
+ failed.push(image.filename)
215
+ console.error(`[im-bridge] 上传成功但无 media_id: ${image.filename}`)
216
+ continue
217
+ }
218
+ await ws.sendMediaMessage(chatid, 'image', mediaId)
219
+ sent++
220
+ console.log(`[im-bridge] 已发送图片 ${image.filename} (${image.buffer.length}B)`)
221
+ } catch (error) {
222
+ failed.push(image.filename)
223
+ const message = error instanceof Error ? error.message : String(error)
224
+ console.error(`[im-bridge] 发送图片失败 ${image.filename}: ${message}`)
225
+ }
226
+ }
227
+ return { sent, failed }
228
+ }
@@ -0,0 +1,198 @@
1
+ /**
2
+ * Route WeCom inbound frames onto one DSH session per chat window:
3
+ * 1:1 by userid, groups by chatid.
4
+ */
5
+
6
+ import { createHash } from 'node:crypto'
7
+
8
+ /** Inbound fields used to pick a session (official `from` / `chattype` / `chatid`). */
9
+ export interface WecomSessionFrame {
10
+ body?: {
11
+ from?: { userid?: string }
12
+ userid?: string
13
+ chatid?: string
14
+ chattype?: string | number
15
+ }
16
+ }
17
+
18
+ /** One WeCom chat window mapped onto a DSH session. */
19
+ export interface WecomSessionRef {
20
+ /** Map key: `single:<userid>` or `group:<chatid>`. */
21
+ key: string
22
+ /** Window kind. */
23
+ kind: 'single' | 'group'
24
+ /** Sender userid for allow-lists; empty when the group frame omitted `from`. */
25
+ sender: string
26
+ /** Group chatid when {@link WecomSessionRef.kind} is `group`. */
27
+ chatid?: string
28
+ }
29
+
30
+ /** Single-chat frame with no userid — caller must refuse, not merge. */
31
+ export class WecomSessionReject extends Error {
32
+ /** Short WeCom reply when the frame cannot be routed. */
33
+ readonly reply: string
34
+
35
+ /**
36
+ * @param reply - text sent back on the inbound frame.
37
+ */
38
+ constructor(reply: string) {
39
+ super(reply)
40
+ this.name = 'WecomSessionReject'
41
+ this.reply = reply
42
+ }
43
+ }
44
+
45
+ /**
46
+ * Sender userid from official `from.userid`, then `body.userid`.
47
+ * Does not read `sender` (not on the SDK message type).
48
+ */
49
+ export function senderUserid(frame: WecomSessionFrame): string {
50
+ const from = frame.body?.from?.userid?.trim()
51
+ if (from) return from
52
+ const body = frame.body?.userid?.trim()
53
+ if (body) return body
54
+ return ''
55
+ }
56
+
57
+ function isGroupChat(chattype: string | number | undefined, chatid: string): boolean {
58
+ if (chattype === 'group' || chattype === 2 || chattype === '2') return true
59
+ if (chatid !== '' && chattype !== 'single' && chattype !== 1 && chattype !== '1') return true
60
+ return false
61
+ }
62
+
63
+ /** GUI channel prefix by window kind (no userid / chatid). */
64
+ export const WECOM_TITLE_PREFIX = {
65
+ single: '企微·私聊',
66
+ group: '企微·群',
67
+ } as const
68
+
69
+ /** Leading `企微·` / previously used `企业微信·` channel labels. */
70
+ const CHANNEL_PREFIX = /^(?:企业微信|企微)·(?:私聊|群)\s*/u
71
+
72
+ /**
73
+ * Sidebar title: channel kind plus first-prompt text, never an id.
74
+ * @param kind - {@link WecomSessionRef.kind}.
75
+ * @param raw - automatic or previously prefixed title.
76
+ */
77
+ export function wecomDisplayTitle(kind: 'single' | 'group', raw: string): string {
78
+ const prefix = WECOM_TITLE_PREFIX[kind]
79
+ const stripped = raw.replace(CHANNEL_PREFIX, '').trim()
80
+ return stripped === '' ? prefix : `${prefix} ${stripped}`
81
+ }
82
+
83
+ /** One leading `@nickname` and its separator; JS `\s` covers WeCom's U+00A0 and U+2005. */
84
+ const LEADING_MENTION = /^@[^\s@]+(?:\s+|$)/u
85
+
86
+ /**
87
+ * Drop the `@bot` mentions WeCom prepends to a group message, so the model
88
+ * input and the generated title both start at the actual request. Mentions
89
+ * later in the text stay; a message that is nothing but mentions is returned
90
+ * unchanged rather than emptied.
91
+ * @param text - trimmed inbound message text.
92
+ */
93
+ export function stripBotMention(text: string): string {
94
+ let rest = text
95
+ for (let match = LEADING_MENTION.exec(rest); match !== null; match = LEADING_MENTION.exec(rest)) {
96
+ rest = rest.slice(match[0].length)
97
+ }
98
+ const stripped = rest.trim()
99
+ return stripped === '' ? text : stripped
100
+ }
101
+
102
+ /** Previously pinned `企微·私聊/群 <id>` titles from the id-based rename. */
103
+ export function isLegacyPinnedWecomTitle(title: string): boolean {
104
+ return /^(?:企微·(?:私聊|群))\s+[A-Za-z0-9][A-Za-z0-9_-]*$/.test(title.trim())
105
+ }
106
+
107
+ function singleRef(sender: string): WecomSessionRef {
108
+ return {
109
+ key: `single:${sender}`,
110
+ kind: 'single',
111
+ sender,
112
+ }
113
+ }
114
+
115
+ /**
116
+ * Stable DSH session id for one epoch of a WeCom window (survives process
117
+ * restart). Epoch 1 carries no suffix, so ids minted before archiving support
118
+ * keep resolving to the same session.
119
+ * @param key - {@link WecomSessionRef.key}.
120
+ * @param epoch - 1-based session generation for this window.
121
+ */
122
+ export function wecomSessionId(key: string, epoch = 1): string {
123
+ const hex = createHash('sha256').update(key).digest('hex').slice(0, 16)
124
+ return epoch <= 1 ? `wecom-${hex}` : `wecom-${hex}-${epoch}`
125
+ }
126
+
127
+ /** Session a WeCom window binds to, and how {@link planWecomBind} reaches it. */
128
+ export interface WecomBindPlan {
129
+ /** DSH session id from {@link wecomSessionId}. */
130
+ readonly sessionId: string
131
+ /** Adopt a live Agent, resume a persisted session, or create a new one. */
132
+ readonly bind: 'adopt' | 'resume' | 'create'
133
+ /** Which epoch {@link WecomBindPlan.sessionId} belongs to. */
134
+ readonly epoch: number
135
+ }
136
+
137
+ /** What the Host currently knows about candidate session ids. */
138
+ export interface WecomBindState {
139
+ /** Whether the Agent registry holds a live Agent for this id. */
140
+ live: (sessionId: string) => boolean
141
+ /** Session ids present in session persistence. */
142
+ stored: ReadonlySet<string>
143
+ /** Session ids in the workspace registry's archive set. */
144
+ archived: ReadonlySet<string>
145
+ }
146
+
147
+ /**
148
+ * Bind a WeCom window to its first non-archived epoch. Archiving a session in
149
+ * the GUI hides it everywhere with no way back, so its epoch is skipped and
150
+ * the window continues in the next one; the chosen id adopts a live Agent,
151
+ * resumes a persisted session, or starts a new one.
152
+ * @param key - {@link WecomSessionRef.key}.
153
+ * @param state - live / persisted / archived knowledge per candidate id.
154
+ */
155
+ export function planWecomBind(key: string, state: WecomBindState): WecomBindPlan {
156
+ // An archived id is necessarily a known session, so the archive set bounds
157
+ // how many epochs can be skipped.
158
+ const limit = state.archived.size + 1
159
+ for (let epoch = 1; epoch <= limit; epoch += 1) {
160
+ const sessionId = wecomSessionId(key, epoch)
161
+ if (state.archived.has(sessionId)) continue
162
+ if (state.live(sessionId)) return { sessionId, bind: 'adopt', epoch }
163
+ if (state.stored.has(sessionId)) return { sessionId, bind: 'resume', epoch }
164
+ return { sessionId, bind: 'create', epoch }
165
+ }
166
+
167
+ throw new Error(`im-bridge: no free session epoch for ${key} within ${String(limit)} candidates`)
168
+ }
169
+
170
+ /**
171
+ * Map one inbound frame to a chat-window session.
172
+ * Group without `chatid` falls back to 1:1 when userid is present.
173
+ * 1:1 without userid throws {@link WecomSessionReject}.
174
+ */
175
+ export function resolveWecomSession(frame: WecomSessionFrame): WecomSessionRef {
176
+ const sender = senderUserid(frame)
177
+ const chattype = frame.body?.chattype
178
+ const chatid = frame.body?.chatid?.trim() ?? ''
179
+ if (isGroupChat(chattype, chatid)) {
180
+ if (chatid === '') {
181
+ if (sender === '') {
182
+ throw new WecomSessionReject('无法识别会话,已忽略')
183
+ }
184
+ console.error(`[im-bridge] 群消息缺少 chatid, 退回单聊 key from=${sender}`)
185
+ return singleRef(sender)
186
+ }
187
+ return {
188
+ key: `group:${chatid}`,
189
+ kind: 'group',
190
+ sender,
191
+ chatid,
192
+ }
193
+ }
194
+ if (sender === '') {
195
+ throw new WecomSessionReject('无法识别发送者,已忽略')
196
+ }
197
+ return singleRef(sender)
198
+ }