@raolin2025/claude-code-node 2.5.2 → 2.6.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.
package/README.md CHANGED
@@ -67,8 +67,25 @@ cc-node --resume session-1747000000000-abc123
67
67
  | `--resume` | `-r` | 恢复会话 ID | |
68
68
  | `--verbose` | `-v` | 详细输出 | `false` |
69
69
  | `--no-stream` | | 禁用流式响应 | `false` |
70
+ | `--stdio` | | **JSON-RPC 服务器模式**(供桥接层/外部客户端接入,见下) | |
70
71
  | `--help` | `-h` | 显示帮助 | |
71
72
 
73
+ ### 🖥️ stdio 服务器模式(--stdio)
74
+
75
+ 以独立子进程形态提供 **JSON-RPC 2.0 over NDJSON** 服务,供外部客户端(VS Code 扩展 / Web / Telegram 等)接入。
76
+ 协议完整定义见 cc-node-bridge 项目 `docs/stdio-protocol.md`。
77
+
78
+ ```bash
79
+ # 启动(stdin/stdout 为协议通道,stderr 为日志)
80
+ cc-node --stdio --api-base http://127.0.0.1:11434/v1 --model qwen2.5:0.5b
81
+
82
+ # 最小请求:initialize
83
+ printf '{"jsonrpc":"2.0","id":1,"method":"initialize"}\n' | cc-node --stdio
84
+ ```
85
+
86
+ 能力:流式输出(event/delta)、多模态(images)、remote 工具执行(toolCall 回传)、
87
+ 会话管理、abort 中断、config 运行时配置。每个接入客户端建议独立子进程(由桥接层管理)。
88
+
72
89
  ### 权限模式
73
90
 
74
91
  | 模式 | 说明 |
@@ -206,10 +223,24 @@ cc-node --model kimi-k2-0711 --api-base https://api.moonshot.cn/v1
206
223
  # OpenAI
207
224
  cc-node --model gpt-4o --api-base https://api.openai.com/v1
208
225
 
209
- # Ollama 本地
210
- cc-node --model qwen2.5 --api-base http://localhost:11434/v1
226
+ # Ollama / 自建本地服务(Ollama / llama.cpp / vLLM 等)
227
+ # 指向 localhost 或内网地址时【无需 apiKey】,直接可用
228
+ cc-node --api-base http://localhost:11434/v1 --model qwen2.5
229
+
230
+ # 内网自建服务(局域网 IP,同样无需 apiKey)
231
+ # 将 <内网IP> 替换为你的服务器地址,如 192.168.x.x
232
+ cc-node --api-base http://<内网IP>:11434/v1 --model qwen3.6:27b
233
+
234
+ # 未指定模型 → 自动拉取服务端 /models 列表供交互选择
235
+ cc-node --api-base http://localhost:11434/v1
211
236
  ```
212
237
 
238
+ > 💡 **自建本地服务支持**:cc-node 会自动识别 apiBase 指向本地/内网
239
+ > (`localhost`、回环、`192.168.x` / `10.x` / `172.16-31.x` 私有段、`.local` 域名)
240
+ > 的自建 LLM 服务(Ollama、llama.cpp、vLLM 等 OpenAI 兼容端点),
241
+ > 此类服务**无需 apiKey 也能正常调用**,且不会强制附加 `Bearer undefined` 头。
242
+ > 云端厂商(DeepSeek/OpenAI/Kimi 等)仍需 apiKey。
243
+
213
244
  [![npm version](https://img.shields.io/npm/v/@raolin2025/claude-code-node.svg)](https://www.npmjs.com/package/@raolin2025/claude-code-node) [![GitHub](https://img.shields.io/badge/GitHub-bg1avd%2Fclaude--code--node-blue)](https://github.com/bg1avd/claude-code-node) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
214
245
  ---
215
246
 
package/package.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "@raolin2025/claude-code-node",
3
- "version": "2.5.2",
3
+ "version": "2.6.2",
4
4
  "description": "Node.js AI Code Agent CLI - Zero dependencies, pure JavaScript, security hardened, multi-channel notifications, Telegram & QQ Bot remote programming, rich media upload, multi-account management",
5
5
  "type": "module",
6
- "main": "src/index.js",
6
+ "main": "src/core/index.js",
7
7
  "bin": {
8
8
  "cc-node": "src/index.js",
9
9
  "cc-notify": "src/channel/notify-daemon.js"
@@ -0,0 +1,62 @@
1
+ /**
2
+ * llm-server 单元测试
3
+ *
4
+ * 覆盖 isLocalLlmServer / isLocalHostname / buildAuthHeaders
5
+ * 用于验证自建本地 LLM 服务(Ollama / llama.cpp / vLLM)识别逻辑
6
+ */
7
+
8
+ import { test, describe } from 'node:test'
9
+ import assert from 'node:assert'
10
+
11
+ import { isLocalLlmServer, isLocalHostname, buildAuthHeaders } from '../utils/llm-server.js'
12
+
13
+ describe('isLocalHostname', () => {
14
+ const localCases = ['localhost', '127.0.0.1', '192.168.1.50', '10.0.0.5', '172.20.1.1',
15
+ '172.16.1.1', '172.31.255.255', '169.254.1.1', 'myhost.local', 'server.lan', '::1', 'fc00::1']
16
+ localCases.forEach(h => {
17
+ test(`应识别为本地: ${h}`, () => assert.strictEqual(isLocalHostname(h), true))
18
+ })
19
+
20
+ // 172.5 不在私有段(172.16-31)
21
+ const nonLocalCases = ['172.5.1.1', '8.8.8.8', '114.114.114.114', 'api.deepseek.com', 'example.com']
22
+ nonLocalCases.forEach(h => {
23
+ test(`不应识别为本地: ${h}`, () => assert.strictEqual(isLocalHostname(h), false))
24
+ })
25
+ })
26
+
27
+ describe('isLocalLlmServer', () => {
28
+ test('Ollama 本机', () => assert.strictEqual(isLocalLlmServer('http://localhost:11434/v1'), true))
29
+ test('Ollama 回环', () => assert.strictEqual(isLocalLlmServer('http://127.0.0.1:11434/v1'), true))
30
+ test('Ollama 内网', () => assert.strictEqual(isLocalLlmServer('http://192.168.1.50:11434/v1'), true))
31
+ test('vLLM 内网 10.x', () => assert.strictEqual(isLocalLlmServer('http://10.0.0.5:8000/v1'), true))
32
+ test('llama.cpp 内网 172.x', () => assert.strictEqual(isLocalLlmServer('http://172.20.1.1:8000/v1'), true))
33
+ test('无端口内网', () => assert.strictEqual(isLocalLlmServer('http://192.168.1.50/v1'), true))
34
+ test('.local 域名', () => assert.strictEqual(isLocalLlmServer('http://myhost.local:11434/v1'), true))
35
+ test('无 /v1 前缀也可', () => assert.strictEqual(isLocalLlmServer('http://192.168.1.50:11434'), true))
36
+
37
+ test('DeepSeek 云端', () => assert.strictEqual(isLocalLlmServer('https://api.deepseek.com/v1'), false))
38
+ test('OpenAI 云端', () => assert.strictEqual(isLocalLlmServer('https://api.openai.com/v1'), false))
39
+ test('通义云端', () => assert.strictEqual(isLocalLlmServer('https://dashscope.aliyuncs.com/v1'), false))
40
+ test('公网 IP', () => assert.strictEqual(isLocalLlmServer('http://114.114.114.114:11434/v1'), false))
41
+ test('空值', () => assert.strictEqual(isLocalLlmServer(''), false))
42
+ })
43
+
44
+ describe('buildAuthHeaders', () => {
45
+ test('本地服务 + 有 key → 带 Bearer', () => {
46
+ assert.deepStrictEqual(buildAuthHeaders('http://192.168.1.50:11434/v1', 'secret'),
47
+ { 'Authorization': 'Bearer secret' })
48
+ })
49
+ test('本地服务 + 无 key → 空对象(不带Authorization)', () => {
50
+ assert.deepStrictEqual(buildAuthHeaders('http://192.168.1.50:11434/v1', ''), {})
51
+ })
52
+ test('云端 + 有 key → 带 Bearer', () => {
53
+ assert.deepStrictEqual(buildAuthHeaders('https://api.deepseek.com/v1', 'sk-x'),
54
+ { 'Authorization': 'Bearer sk-x' })
55
+ })
56
+ test('云端 + 无 key → undefined', () => {
57
+ assert.strictEqual(buildAuthHeaders('https://api.deepseek.com/v1', ''), undefined)
58
+ })
59
+ test('空 apiBase + 无 key → undefined', () => {
60
+ assert.strictEqual(buildAuthHeaders('', ''), undefined)
61
+ })
62
+ })
@@ -26,6 +26,7 @@ import {
26
26
  mkdirSync, openSync, closeSync,
27
27
  } from 'node:fs'
28
28
  import { join } from 'node:path'
29
+ import { pathToFileURL } from 'node:url'
29
30
  import { homedir } from 'node:os'
30
31
  import { spawn, execSync } from 'node:child_process'
31
32
  import { createConnection } from 'node:net'
@@ -181,12 +182,36 @@ function sendToExistingNode(socketPath, text) {
181
182
  function spawnNewNode(ccNodePath, text) {
182
183
  return new Promise((resolve) => {
183
184
  const timeout = 180000 // 3分钟超时
185
+ // 用 let 在 setTimeout 之前声明 child,避免 TDZ / 时序问题导致 "child is not defined"
186
+ let child = null
187
+ let finished = false
188
+ const finish = (val) => {
189
+ if (finished) return
190
+ finished = true
191
+ if (timer) { clearTimeout(timer); timer = null }
192
+ resolve(val)
193
+ }
184
194
  let timer = setTimeout(() => {
185
- child.kill()
186
- resolve({ type: 'reply', text: '⏰ 执行超时(3 分钟)' })
195
+ try { if (child && child.kill) child.kill() } catch {}
196
+ finish({ type: 'reply', text: '⏰ 执行超时(3 分钟)' })
187
197
  }, timeout)
188
198
  try {
189
- const child = spawn(ccNodePath, [text], {
199
+ // 一次性模式必须显式传 --api-key / --api-base / --model,
200
+ // 因为 cc-node 的 cli.js 只从命令行参数或 config 读取 apiKey,不读 LLM_API_KEY 环境变量。
201
+ // 注意:cc-node 的 parseArgs 在遇到第一个非 '-' 参数(prompt)后会跳过后续选项,
202
+ // 所以选项必须放在 prompt 之前。
203
+ const args = []
204
+ const apiKey = process.env.LLM_API_KEY || process.env.DEEPSEEK_API_KEY || process.env.OPENAI_API_KEY || process.env.QWEN_API_KEY || process.env.GLM_API_KEY || process.env.KIMI_API_KEY || ''
205
+ const apiBase = process.env.LLM_API_BASE || ''
206
+ // 仅在显式指定时传 --model,否则让 cli.js 使用默认模型,避免硬编码不存在的模型名导致一次性节点失败
207
+ const model = process.env.CC_NODE_ONESHOT_MODEL || ''
208
+ if (apiKey) args.push('--api-key', apiKey)
209
+ if (apiBase) args.push('--api-base', apiBase)
210
+ if (model) args.push('--model', model)
211
+ args.push('--no-stream')
212
+ args.push(text)
213
+
214
+ child = spawn(ccNodePath, args, {
190
215
  stdio: ['pipe', 'pipe', 'pipe'],
191
216
  env: { ...process.env, CC_NODE_ONESHOT: '1' },
192
217
  timeout,
@@ -196,25 +221,19 @@ function spawnNewNode(ccNodePath, text) {
196
221
  child.stdout.on('data', (d) => (stdout += d.toString()))
197
222
  child.stderr.on('data', (d) => (stderr += d.toString()))
198
223
  child.on('close', (code) => {
199
- clearTimeout(timer)
200
- timer = null
201
224
  if (stdout.trim()) {
202
- resolve({ type: 'reply', text: stdout.trim().slice(0, 4000) })
225
+ finish({ type: 'reply', text: stdout.trim().slice(0, 4000) })
203
226
  } else if (stderr.trim()) {
204
- resolve({ type: 'reply', text: `❌ Error: ${stderr.trim().slice(0, 1000)}` })
227
+ finish({ type: 'reply', text: `❌ Error: ${stderr.trim().slice(0, 1000)}` })
205
228
  } else {
206
- resolve({ type: 'reply', text: `(completed with code ${code})` })
229
+ finish({ type: 'reply', text: `(completed with code ${code})` })
207
230
  }
208
231
  })
209
232
  child.on('error', (e) => {
210
- clearTimeout(timer)
211
- timer = null
212
- resolve({ type: 'reply', text: `❌ Failed: ${e.message}` })
233
+ finish({ type: 'reply', text: `❌ Failed: ${e.message}` })
213
234
  })
214
235
  } catch (e) {
215
- clearTimeout(timer)
216
- timer = null
217
- resolve({ type: 'reply', text: `❌ Failed: ${e.message}` })
236
+ finish({ type: 'reply', text: `❌ Failed: ${e.message}` })
218
237
  }
219
238
  })
220
239
  }
@@ -225,6 +244,11 @@ async function routeMessage(text, config) {
225
244
  log(`[route] cc-node running → forwarding via socket`)
226
245
  try {
227
246
  const reply = await sendToExistingNode(nodeInfo.socketPath, text)
247
+ // 如果 socket 返回的是错误类型(如"引擎正在运行中"),回退到一次性模式
248
+ if (reply && reply.type === 'error') {
249
+ log(`[route] socket returned error (${reply.text}) → spawning new one-shot node`)
250
+ return (await spawnNewNode(config.ccNodePath, text)).text
251
+ }
228
252
  return reply.text || JSON.stringify(reply)
229
253
  } catch (e) {
230
254
  log(`[route] socket forward failed: ${e.message} → spawning new`)
@@ -821,7 +845,70 @@ async function main() {
821
845
  setInterval(() => {}, 60000)
822
846
  }
823
847
 
824
- main().catch((err) => {
825
- console.error('Fatal error:', err.message)
826
- process.exit(1)
827
- })
848
+ // 仅当 notify-daemon.js 作为主入口直接运行时才启动守护进程。
849
+ // 被 cli.js 通过 import() 引入(--with-notify 模式)时,只导出 startBuiltinListeners,
850
+ // 避免 import 副作用重复启动 HTTP 服务 / QQ 监听器 / PID 文件。
851
+ const isMainEntry = (() => {
852
+ try {
853
+ return process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href
854
+ } catch {
855
+ return false
856
+ }
857
+ })()
858
+
859
+ if (isMainEntry) {
860
+ main().catch((err) => {
861
+ console.error('Fatal error:', err.message)
862
+ process.exit(1)
863
+ })
864
+ }
865
+
866
+ // ============================================================
867
+ // --with-notify: 内置监听器(由 cli.js 调用,无需 fork 新进程)
868
+ // ============================================================
869
+
870
+ /**
871
+ * 在 cc-node 主进程中启动频道监听器(Telegram),
872
+ * 替代外部 bash 脚本启动 cc-notify 的方式。
873
+ * 跨平台兼容 — 不依赖 bash / nohup / PID 文件。
874
+ *
875
+ * @param {object} opts
876
+ * @param {object} opts.channels - 频道配置对象
877
+ * @param {string|null} opts.defaultChannel - 默认频道
878
+ * @param {function} opts.onMessage - 消息回调 (msg) => void
879
+ * @returns {Promise<{tgListener: object|null, stop: function}>}
880
+ */
881
+ export async function startBuiltinListeners(opts = {}) {
882
+ const { channels = {}, defaultChannel = null, onMessage } = opts
883
+
884
+ const config = {
885
+ channels,
886
+ defaultChannel: defaultChannel || process.env.CC_NODE_CHANNEL_DEFAULT || null,
887
+ }
888
+
889
+ let tgListener = null
890
+
891
+ // 启动 Telegram 监听器
892
+ if (config.channels.telegram?.token) {
893
+ try {
894
+ const { TelegramListener } = await import('./tg-listener.js')
895
+ tgListener = new TelegramListener(config)
896
+ if (tgListener.bot) {
897
+ tgListener.start(async (msg) => {
898
+ msg.channel = 'telegram'
899
+ await onMessage(msg)
900
+ }).catch(e => console.error(`[builtin-notify] TG listener error: ${e.message}`))
901
+ console.log(`[builtin-notify] ✅ Telegram listener started`)
902
+ }
903
+ } catch (e) {
904
+ console.error(`[builtin-notify] TG init failed: ${e.message}`)
905
+ }
906
+ }
907
+
908
+ return {
909
+ tgListener,
910
+ stop() {
911
+ tgListener?.stop()
912
+ },
913
+ }
914
+ }
@@ -269,13 +269,9 @@ export class QQBot {
269
269
  }
270
270
 
271
271
  async _getWSURL() {
272
- const token = await this._t()
273
- const res = await fetch(`${API_BASE}/websocket`, {
274
- headers: { Authorization: `QQBot ${token}` },
275
- })
276
- if (!res.ok) throw new Error(`WS URL ${res.status}`)
277
- const data = await res.json()
278
- return data.url
272
+ // QQ Bot API v2 的 /websocket 端点需要 WebSocket 升级请求,不能用 HTTP fetch
273
+ // 直接使用固定的 WebSocket URL
274
+ return 'wss://api.sgroup.qq.com/websocket'
279
275
  }
280
276
 
281
277
  _handleWS(msg) {
@@ -91,7 +91,7 @@ class RateLimiter {
91
91
  // Telegram Bot 客户端
92
92
  // ============================================================
93
93
 
94
- class TelegramBotClient {
94
+ export class TelegramBotClient {
95
95
  constructor(token, opts = {}) {
96
96
  this.token = token
97
97
  this.apiBase = opts.apiBase || API_BASE(token)
@@ -35,11 +35,14 @@ export function socks5Connect(proxyHost, proxyPort, targetHost, targetPort, opts
35
35
 
36
36
  socket.once('connect', async () => {
37
37
  try {
38
+ // 创建持久的字节缓冲读取器,避免多次 readBytes 丢失多余字节
39
+ const reader = createByteReader(socket)
40
+
38
41
  // Step 1: 握手 — 协商认证方式
39
42
  const authMethods = opts.username ? [0x00, 0x02] : [0x00] // 无认证 + 用户名密码
40
43
  socket.write(Buffer.from([0x05, authMethods.length, ...authMethods]))
41
44
 
42
- const handshake = await readBytes(socket, 2)
45
+ const handshake = await reader.read(2)
43
46
  if (handshake[0] !== 0x05) {
44
47
  throw new Error('SOCKS5: 版本不匹配')
45
48
  }
@@ -51,7 +54,7 @@ export function socks5Connect(proxyHost, proxyPort, targetHost, targetPort, opts
51
54
  const p = Buffer.from(opts.password, 'utf8')
52
55
  const authReq = Buffer.from([0x01, u.length, ...u, p.length, ...p])
53
56
  socket.write(authReq)
54
- const authResp = await readBytes(socket, 2)
57
+ const authResp = await reader.read(2)
55
58
  if (authResp[1] !== 0x00) throw new Error('SOCKS5: 认证失败')
56
59
  } else if (handshake[1] !== 0x00) {
57
60
  throw new Error('SOCKS5: 代理不支持不需要的认证方式')
@@ -72,7 +75,7 @@ export function socks5Connect(proxyHost, proxyPort, targetHost, targetPort, opts
72
75
  const connectReq = Buffer.from([0x05, 0x01, 0x00, hostType, ...addr, ...portBuf])
73
76
  socket.write(connectReq)
74
77
 
75
- const connectResp = await readBytes(socket, 4)
78
+ const connectResp = await reader.read(4)
76
79
  if (connectResp[0] !== 0x05 || connectResp[1] !== 0x00) {
77
80
  const errors = { 0x01: '通用错误', 0x02: '不允许', 0x03: '网络不可达', 0x04: '主机不可达', 0x05: '连接被拒', 0x06: 'TTL超时', 0x07: '命令不支持', 0x08: '地址类型不支持' }
78
81
  throw new Error(`SOCKS5: 连接失败 — ${errors[connectResp[1]] || `错误码 ${connectResp[1]}`}`)
@@ -80,11 +83,14 @@ export function socks5Connect(proxyHost, proxyPort, targetHost, targetPort, opts
80
83
 
81
84
  // 读取剩余响应包头(根据地址类型)
82
85
  const addrType = connectResp[3]
83
- if (addrType === 0x01) await readBytes(socket, 6) // IPv4 + port
86
+ if (addrType === 0x01) await reader.read(6) // IPv4 + port
84
87
  else if (addrType === 0x03) {
85
- const len = (await readBytes(socket, 1))[0]
86
- await readBytes(socket, len + 2) // hostname + port
87
- } else if (addrType === 0x04) await readBytes(socket, 18) // IPv6 + port
88
+ const len = (await reader.read(1))[0]
89
+ await reader.read(len + 2) // hostname + port
90
+ } else if (addrType === 0x04) await reader.read(18) // IPv6 + port
91
+
92
+ // 清理 reader 的监听器,确保隧道建立后数据完整交给调用方
93
+ reader.detach()
88
94
 
89
95
  clearTimeout(timeout)
90
96
  resolve(socket)
@@ -102,6 +108,85 @@ export function socks5Connect(proxyHost, proxyPort, targetHost, targetPort, opts
102
108
  })
103
109
  }
104
110
 
111
+ /**
112
+ * 创建持久的字节缓冲读取器。
113
+ *
114
+ * 原实现每次 readBytes 都重新注册 'data' 监听器,当 SOCKS5 代理
115
+ * 一次性返回多段响应时,首个 readBytes 会消费掉多余字节并丢弃,
116
+ * 导致后续 readBytes 永远等待,最终触发超时。此读取器用统一的
117
+ * 内部缓冲队列累积所有到达的字节,保证多次读取之间字节不丢失。
118
+ *
119
+ * @param {import('node:net').Socket} socket
120
+ */
121
+ function createByteReader(socket) {
122
+ let buffer = Buffer.alloc(0)
123
+ let closed = false
124
+ const waiters = []
125
+
126
+ const onData = (chunk) => {
127
+ buffer = Buffer.concat([buffer, chunk])
128
+ flush()
129
+ }
130
+
131
+ const onError = () => {
132
+ closed = true
133
+ flush()
134
+ }
135
+
136
+ const onEnd = () => {
137
+ closed = true
138
+ flush()
139
+ }
140
+
141
+ socket.on('data', onData)
142
+ socket.once('error', onError)
143
+ socket.once('end', onEnd)
144
+
145
+ function flush() {
146
+ while (waiters.length > 0) {
147
+ const waiter = waiters[0]
148
+ if (buffer.length >= waiter.n) {
149
+ waiters.shift()
150
+ const out = buffer.slice(0, waiter.n)
151
+ buffer = buffer.slice(waiter.n)
152
+ waiter.resolve(out)
153
+ } else {
154
+ break
155
+ }
156
+ }
157
+ // 所有等待者都已满足,但连接已关闭且字节不足 -> 报错
158
+ if (closed && waiters.length > 0 && buffer.length === 0) {
159
+ const waiter = waiters.shift()
160
+ waiter.reject(new Error('SOCKS5: 连接意外关闭'))
161
+ }
162
+ }
163
+
164
+ return {
165
+ read(n) {
166
+ return new Promise((resolve, reject) => {
167
+ if (n === 0) return resolve(Buffer.alloc(0))
168
+ // 先检查已有缓冲
169
+ if (buffer.length >= n) {
170
+ const out = buffer.slice(0, n)
171
+ buffer = buffer.slice(n)
172
+ return resolve(out)
173
+ }
174
+ // 连接已关闭且缓冲不足
175
+ if (closed) {
176
+ return reject(new Error('SOCKS5: 连接意外关闭'))
177
+ }
178
+ waiters.push({ n, resolve, reject })
179
+ flush()
180
+ })
181
+ },
182
+ detach() {
183
+ socket.removeListener('data', onData)
184
+ socket.removeListener('error', onError)
185
+ socket.removeListener('end', onEnd)
186
+ },
187
+ }
188
+ }
189
+
105
190
  /**
106
191
  * 创建通过 SOCKS5 代理的 TLS 连接
107
192
  *
@@ -171,8 +256,18 @@ export async function fetchViaSocks5(url, options = {}, proxyAddr) {
171
256
  // 构建 HTTP 请求
172
257
  const path = parsedUrl.pathname + parsedUrl.search
173
258
  const headers = Object.entries(options.headers || {}).map(([k, v]) => `${k}: ${v}`).join('\r\n')
259
+ // body 必须是字符串/Buffer;FormData/Blob 等 multipart 正文当前不支持
260
+ // (仅用于 Telegram Bot API 的 JSON 请求),遇到非字符串 body 给出清晰错误而非崩溃。
174
261
  const body = options.body || ''
175
- const req = `${options.method || 'GET'} ${path} HTTP/1.1\r\nHost: ${host}\r\n${headers ? headers + '\r\n' : ''}Content-Length: ${body.length}\r\nConnection: close\r\n\r\n${body}`
262
+ if (typeof body !== 'string' && !Buffer.isBuffer(body)) {
263
+ socket.destroy()
264
+ return Promise.reject(new Error('fetchViaSocks5: 仅支持 string/Buffer 请求体(不支持 FormData 等 multipart)'))
265
+ }
266
+ // 注意:Content-Length 必须用 UTF-8 字节数,不能用字符串字符数,
267
+ // 否则包含中文/emoji 时会导致服务器读不完整请求体。
268
+ const bodyLen = Buffer.byteLength(body, 'utf8')
269
+ const head = `${options.method || 'GET'} ${path} HTTP/1.1\r\nHost: ${host}\r\n${headers ? headers + '\r\n' : ''}Content-Length: ${bodyLen}\r\nConnection: close\r\n\r\n`
270
+ const reqBuf = Buffer.concat([Buffer.from(head, 'utf8'), Buffer.isBuffer(body) ? body : Buffer.from(body, 'utf8')])
176
271
 
177
272
  return new Promise((resolve, reject) => {
178
273
  let responseData = ''
@@ -181,7 +276,7 @@ export async function fetchViaSocks5(url, options = {}, proxyAddr) {
181
276
  reject(new Error('HTTP request timeout'))
182
277
  }, 30000)
183
278
 
184
- socket.write(req)
279
+ socket.write(reqBuf)
185
280
  socket.on('data', (chunk) => {
186
281
  responseData += chunk.toString()
187
282
  })
@@ -211,26 +306,4 @@ export async function fetchViaSocks5(url, options = {}, proxyAddr) {
211
306
  reject(err)
212
307
  })
213
308
  })
214
- }
215
-
216
- /** 从 socket 读取指定字节数 */
217
- function readBytes(socket, n) {
218
- return new Promise((resolve, reject) => {
219
- if (n === 0) return resolve(Buffer.alloc(0))
220
- let buf = Buffer.alloc(0)
221
- const onData = (chunk) => {
222
- buf = Buffer.concat([buf, chunk])
223
- if (buf.length >= n) {
224
- socket.removeListener('data', onData)
225
- resolve(buf.slice(0, n))
226
- }
227
- }
228
- socket.on('data', onData)
229
- socket.once('error', reject)
230
- // 处理已经缓冲的数据
231
- if (buf.length >= n) {
232
- socket.removeListener('data', onData)
233
- resolve(buf.slice(0, n))
234
- }
235
- })
236
- }
309
+ }