@mhfire/dsh-im-bridge 0.1.7 → 0.3.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/README.en.md +76 -3
- package/README.md +76 -3
- package/cordis.patch.yml +4 -0
- package/lib/client.js +789 -153
- package/lib/client.js.map +1 -0
- package/lib/index.js +1030 -0
- package/package.json +36 -4
- package/persona.default.en.md +1 -0
- package/persona.default.md +2 -1
- package/persona.example.md +2 -1
- package/src/client/PluginCard.module.css +153 -0
- package/src/client/PluginCard.tsx +83 -0
- package/src/client/WecomCard.tsx +135 -0
- package/src/client/card-controller.ts +146 -0
- package/src/client/card-form.ts +293 -0
- package/src/client/css-modules.d.ts +4 -0
- package/src/client/fields.module.css +124 -0
- package/src/client/fields.tsx +112 -0
- package/src/client/index.ts +37 -0
- package/src/client/locales.ts +112 -0
- package/src/index.ts +787 -0
- package/src/reply-images.ts +228 -0
- package/src/session-key.ts +197 -0
- package/src/{wecom.js → wecom.ts} +98 -56
- package/tsconfig.json +14 -0
- package/tsdown.client.ts +109 -0
- package/tsdown.host.ts +36 -0
- package/src/index.js +0 -401
|
@@ -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
|
+
/** `` 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,197 @@
|
|
|
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
|
+
throw new Error(`im-bridge: no free session epoch for ${key} within ${String(limit)} candidates`)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Map one inbound frame to a chat-window session.
|
|
171
|
+
* Group without `chatid` falls back to 1:1 when userid is present.
|
|
172
|
+
* 1:1 without userid throws {@link WecomSessionReject}.
|
|
173
|
+
*/
|
|
174
|
+
export function resolveWecomSession(frame: WecomSessionFrame): WecomSessionRef {
|
|
175
|
+
const sender = senderUserid(frame)
|
|
176
|
+
const chattype = frame.body?.chattype
|
|
177
|
+
const chatid = frame.body?.chatid?.trim() ?? ''
|
|
178
|
+
if (isGroupChat(chattype, chatid)) {
|
|
179
|
+
if (chatid === '') {
|
|
180
|
+
if (sender === '') {
|
|
181
|
+
throw new WecomSessionReject('无法识别会话,已忽略')
|
|
182
|
+
}
|
|
183
|
+
console.error(`[im-bridge] 群消息缺少 chatid, 退回单聊 key from=${sender}`)
|
|
184
|
+
return singleRef(sender)
|
|
185
|
+
}
|
|
186
|
+
return {
|
|
187
|
+
key: `group:${chatid}`,
|
|
188
|
+
kind: 'group',
|
|
189
|
+
sender,
|
|
190
|
+
chatid,
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
if (sender === '') {
|
|
194
|
+
throw new WecomSessionReject('无法识别发送者,已忽略')
|
|
195
|
+
}
|
|
196
|
+
return singleRef(sender)
|
|
197
|
+
}
|
|
@@ -1,12 +1,45 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
2
|
+
* WeCom stream helpers: thinking animation, final brief, and send retries.
|
|
3
|
+
* The WeCom SDK is imported only on the final-send retry path so plugin load
|
|
4
|
+
* does not pull it in before Loader settle.
|
|
4
5
|
*/
|
|
5
|
-
import { generateReqId } from '@wecom/aibot-node-sdk'
|
|
6
6
|
|
|
7
|
-
/**
|
|
8
|
-
export
|
|
9
|
-
/**
|
|
7
|
+
/** One timed fallback line while no model chunk has arrived. */
|
|
8
|
+
export interface ThinkingPhase {
|
|
9
|
+
/** Elapsed seconds at which this line becomes current. */
|
|
10
|
+
atSec: number
|
|
11
|
+
/** Status text shown in the stream. */
|
|
12
|
+
text: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Stream-animation copy and timing. */
|
|
16
|
+
export interface ThinkingConfig {
|
|
17
|
+
/** Timed fallback lines while no `assistant/chunk` has arrived. */
|
|
18
|
+
phases: ThinkingPhase[]
|
|
19
|
+
/** Idle-phase spinner glyphs. */
|
|
20
|
+
spin: string[]
|
|
21
|
+
/** Extra lines after {@link ThinkingConfig.eggAfterSec}. */
|
|
22
|
+
eggs: string[]
|
|
23
|
+
/** Seconds after which eggs start rotating. */
|
|
24
|
+
eggAfterSec: number
|
|
25
|
+
/** Stream refresh interval. */
|
|
26
|
+
intervalMs: number
|
|
27
|
+
/** Prefix before a friendly tool name. */
|
|
28
|
+
activityPrefix: string
|
|
29
|
+
/** Lines rotated during `reasoning-delta`. */
|
|
30
|
+
reasoningStatus: string[]
|
|
31
|
+
/** Lines rotated during `text-delta`. */
|
|
32
|
+
outputStatus: string[]
|
|
33
|
+
/** Spinner glyphs during reasoning. */
|
|
34
|
+
reasoningSpin: string[]
|
|
35
|
+
/** Spinner glyphs during output. */
|
|
36
|
+
outputSpin: string[]
|
|
37
|
+
/** Tool registration name → WeCom-visible label. */
|
|
38
|
+
toolLabels: Record<string, string>
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Default stream-animation copy; Config / cordis patch may override. */
|
|
42
|
+
export const DEFAULT_THINKING: ThinkingConfig = {
|
|
10
43
|
phases: [
|
|
11
44
|
{ atSec: 0, text: '🤔 正在理解你的需求…' },
|
|
12
45
|
{ atSec: 8, text: '📋 正在整理任务清单' },
|
|
@@ -27,15 +60,10 @@ export const DEFAULT_THINKING = {
|
|
|
27
60
|
eggAfterSec: 240,
|
|
28
61
|
intervalMs: 1500,
|
|
29
62
|
activityPrefix: '🛠️ 正在执行 ',
|
|
30
|
-
/** 收到 reasoning-delta 时的状态行文案(按刷新轮换) */
|
|
31
63
|
reasoningStatus: ['💭 模型思考中…', '🧠 深入分析中…', '✨ 梳理思路中…'],
|
|
32
|
-
/** 收到 text-delta 时的状态行文案(按刷新轮换) */
|
|
33
64
|
outputStatus: ['✍️ 正在输出回复…', '📝 组织文字中…', '💬 生成回答中…'],
|
|
34
|
-
/** 思考阶段专用旋转表情 */
|
|
35
65
|
reasoningSpin: ['💭', '🧠', '🌀', '✨'],
|
|
36
|
-
/** 输出阶段专用旋转表情 */
|
|
37
66
|
outputSpin: ['✍️', '📝', '💬', '⚡'],
|
|
38
|
-
/** 工具名 → 企微活动文案友好名(可被 thinking.toolLabels 覆盖) */
|
|
39
67
|
toolLabels: {
|
|
40
68
|
pwsh: 'PowerShell',
|
|
41
69
|
bash: 'Shell',
|
|
@@ -53,13 +81,13 @@ export const DEFAULT_THINKING = {
|
|
|
53
81
|
},
|
|
54
82
|
}
|
|
55
83
|
|
|
56
|
-
/**
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
*/
|
|
62
|
-
export function labelTool(name, thinking) {
|
|
84
|
+
/** WeCom client methods used by the animation and final send. */
|
|
85
|
+
export interface WecomStreamClient {
|
|
86
|
+
replyStream(frame: unknown, streamId: string, content: string, finish: boolean): Promise<unknown>
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Map a tool registration name to the WeCom-visible label. */
|
|
90
|
+
export function labelTool(name: string, thinking?: Partial<ThinkingConfig>): string {
|
|
63
91
|
const labels = {
|
|
64
92
|
...DEFAULT_THINKING.toolLabels,
|
|
65
93
|
...(thinking?.toolLabels && typeof thinking.toolLabels === 'object' ? thinking.toolLabels : {}),
|
|
@@ -67,25 +95,22 @@ export function labelTool(name, thinking) {
|
|
|
67
95
|
return labels[name] || name
|
|
68
96
|
}
|
|
69
97
|
|
|
70
|
-
/**
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
export function pickStatusLine(value, fallback, tick) {
|
|
98
|
+
/** Pick one status line from a configured list, rotating by tick. */
|
|
99
|
+
export function pickStatusLine(
|
|
100
|
+
value: string | string[] | undefined,
|
|
101
|
+
fallback: string[],
|
|
102
|
+
tick: number,
|
|
103
|
+
): string {
|
|
77
104
|
const list = Array.isArray(value) && value.length > 0
|
|
78
105
|
? value
|
|
79
106
|
: (typeof value === 'string' && value !== '' ? [value] : fallback)
|
|
80
|
-
return list[Math.abs(tick) % list.length]
|
|
107
|
+
return list[Math.abs(tick) % list.length] ?? fallback[0] ?? ''
|
|
81
108
|
}
|
|
82
109
|
|
|
83
|
-
/**
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
*/
|
|
88
|
-
export function streamPhaseFromChunk(chunk) {
|
|
110
|
+
/** Infer the model stream phase from one `assistant/chunk` payload. */
|
|
111
|
+
export function streamPhaseFromChunk(
|
|
112
|
+
chunk: { type?: string; blockType?: string } | undefined,
|
|
113
|
+
): 'reasoning' | 'outputting' | null {
|
|
89
114
|
if (!chunk || typeof chunk !== 'object') return null
|
|
90
115
|
if (chunk.type === 'reasoning-delta') return 'reasoning'
|
|
91
116
|
if (chunk.type === 'text-delta') return 'outputting'
|
|
@@ -96,8 +121,8 @@ export function streamPhaseFromChunk(chunk) {
|
|
|
96
121
|
return null
|
|
97
122
|
}
|
|
98
123
|
|
|
99
|
-
/**
|
|
100
|
-
export function fmtDuration(ms) {
|
|
124
|
+
/** Format milliseconds as a short Chinese duration. */
|
|
125
|
+
export function fmtDuration(ms: number): string {
|
|
101
126
|
const s = Math.floor(ms / 1000)
|
|
102
127
|
if (s < 60) return `${s} 秒`
|
|
103
128
|
const m = Math.floor(s / 60)
|
|
@@ -105,38 +130,47 @@ export function fmtDuration(ms) {
|
|
|
105
130
|
return r > 0 ? `${m} 分 ${r} 秒` : `${m} 分钟`
|
|
106
131
|
}
|
|
107
132
|
|
|
108
|
-
/**
|
|
109
|
-
export function speedOf(ms) {
|
|
133
|
+
/** Speed label from elapsed milliseconds. */
|
|
134
|
+
export function speedOf(ms: number): string {
|
|
110
135
|
if (ms < 60000) return '⚡ 神速'
|
|
111
136
|
if (ms < 180000) return '🚀 正常速度'
|
|
112
137
|
return '🐢 耗时较长'
|
|
113
138
|
}
|
|
114
139
|
|
|
115
|
-
/**
|
|
116
|
-
export function footerOf(ms) {
|
|
140
|
+
/** Footer appended to a completed WeCom reply. */
|
|
141
|
+
export function footerOf(ms: number): string {
|
|
117
142
|
if (ms >= 180000) {
|
|
118
143
|
return `\n\n---\n✅ 执行完成 · 🐢 耗时较长(${fmtDuration(ms)})\n💡 如需提速,可让我把诊断步骤合并成更少的 SSH 批次`
|
|
119
144
|
}
|
|
120
145
|
return `\n\n---\n✅ 执行完成 · ${speedOf(ms)}(${fmtDuration(ms)})`
|
|
121
146
|
}
|
|
122
147
|
|
|
123
|
-
/**
|
|
124
|
-
export function truncate(text, max) {
|
|
148
|
+
/** Truncate a string to at most `max` UTF-8 bytes. */
|
|
149
|
+
export function truncate(text: string, max: number): string {
|
|
125
150
|
if (Buffer.byteLength(text, 'utf8') <= max) return text
|
|
126
151
|
let t = text
|
|
127
152
|
while (Buffer.byteLength(t, 'utf8') > max) t = t.slice(0, -100)
|
|
128
|
-
return t
|
|
153
|
+
return `${t}\n\n...(内容过长已截断)`
|
|
129
154
|
}
|
|
130
155
|
|
|
131
156
|
/**
|
|
132
|
-
*
|
|
133
|
-
*
|
|
134
|
-
* @param
|
|
135
|
-
* @param
|
|
136
|
-
* @
|
|
157
|
+
* Refresh one stream message until the caller stops it.
|
|
158
|
+
* @param activity - live tool/status text; empty falls back to timed phases.
|
|
159
|
+
* @param thinking - animation copy; defaults to {@link DEFAULT_THINKING}.
|
|
160
|
+
* @param getStreamPhase - model stream phase; selects the spinner pool.
|
|
161
|
+
* @returns disposer that cancels the interval.
|
|
137
162
|
*/
|
|
138
|
-
export function startThinking(
|
|
139
|
-
|
|
163
|
+
export function startThinking(
|
|
164
|
+
ws: WecomStreamClient,
|
|
165
|
+
frame: unknown,
|
|
166
|
+
streamId: string,
|
|
167
|
+
startedAt: number,
|
|
168
|
+
timeoutSec: number,
|
|
169
|
+
activity?: () => string,
|
|
170
|
+
thinking?: Partial<ThinkingConfig>,
|
|
171
|
+
getStreamPhase?: () => 'idle' | 'reasoning' | 'outputting' | string,
|
|
172
|
+
): () => void {
|
|
173
|
+
const t = { ...DEFAULT_THINKING, ...thinking }
|
|
140
174
|
const phases = Array.isArray(t.phases) && t.phases.length > 0 ? t.phases : DEFAULT_THINKING.phases
|
|
141
175
|
const spin = Array.isArray(t.spin) && t.spin.length > 0 ? t.spin : DEFAULT_THINKING.spin
|
|
142
176
|
const reasoningSpin = Array.isArray(t.reasoningSpin) && t.reasoningSpin.length > 0
|
|
@@ -153,10 +187,10 @@ export function startThinking(ws, frame, streamId, startedAt, timeoutSec, activi
|
|
|
153
187
|
const timer = setInterval(() => {
|
|
154
188
|
const secs = Math.floor((Date.now() - startedAt) / 1000)
|
|
155
189
|
const live = activity ? activity() : ''
|
|
156
|
-
let stage = phases[0]
|
|
190
|
+
let stage = phases[0]?.text ?? ''
|
|
157
191
|
if (!live) {
|
|
158
|
-
for (const
|
|
159
|
-
if (secs >=
|
|
192
|
+
for (const phase of phases) {
|
|
193
|
+
if (secs >= phase.atSec) stage = phase.text
|
|
160
194
|
}
|
|
161
195
|
}
|
|
162
196
|
const pct = Math.min(Math.floor((secs / total) * 100), 99)
|
|
@@ -176,17 +210,25 @@ export function startThinking(ws, frame, streamId, startedAt, timeoutSec, activi
|
|
|
176
210
|
const emoji = emojiPool[i % emojiPool.length]
|
|
177
211
|
i++
|
|
178
212
|
const status = live || stage
|
|
179
|
-
ws.replyStream(frame, streamId, `${emoji} ${status} ⏱ ${secs} 秒${remainTxt}${bar}${egg}`, false)
|
|
213
|
+
void ws.replyStream(frame, streamId, `${emoji} ${status} ⏱ ${secs} 秒${remainTxt}${bar}${egg}`, false)
|
|
214
|
+
.catch(() => {})
|
|
180
215
|
}, intervalMs)
|
|
181
216
|
return () => clearInterval(timer)
|
|
182
217
|
}
|
|
183
218
|
|
|
184
|
-
/**
|
|
185
|
-
export async function sendFinal(
|
|
219
|
+
/** Finish the current stream; open a new stream if WeCom expired the first. */
|
|
220
|
+
export async function sendFinal(
|
|
221
|
+
ws: WecomStreamClient,
|
|
222
|
+
frame: unknown,
|
|
223
|
+
streamId: string,
|
|
224
|
+
content: string,
|
|
225
|
+
): Promise<void> {
|
|
186
226
|
try {
|
|
187
227
|
await ws.replyStream(frame, streamId, content, true)
|
|
188
|
-
} catch (
|
|
189
|
-
|
|
228
|
+
} catch (error) {
|
|
229
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
230
|
+
console.error(`[im-bridge] 原流最终回复失败(${message}), 尝试新流...`)
|
|
231
|
+
const { generateReqId } = await import('@wecom/aibot-node-sdk')
|
|
190
232
|
await ws.replyStream(frame, generateReqId('stream'), content, true)
|
|
191
233
|
}
|
|
192
234
|
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "NodeNext",
|
|
5
|
+
"moduleResolution": "NodeNext",
|
|
6
|
+
"jsx": "react-jsx",
|
|
7
|
+
"strict": true,
|
|
8
|
+
"skipLibCheck": true,
|
|
9
|
+
"noEmit": true,
|
|
10
|
+
"verbatimModuleSyntax": true,
|
|
11
|
+
"allowImportingTsExtensions": true
|
|
12
|
+
},
|
|
13
|
+
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.d.ts", "tests/**/*.ts"]
|
|
14
|
+
}
|