mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-15 21:05:51 +00:00
feat: 接入 weixin 服务层与命令入口
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
57
bun.lock
57
bun.lock
@@ -17,20 +17,20 @@
|
||||
"@ant/computer-use-swift": "workspace:*",
|
||||
"@ant/model-provider": "workspace:*",
|
||||
"@anthropic-ai/bedrock-sdk": "^0.26.4",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.87",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.114",
|
||||
"@anthropic-ai/foundry-sdk": "^0.2.3",
|
||||
"@anthropic-ai/mcpb": "^2.1.2",
|
||||
"@anthropic-ai/sandbox-runtime": "^0.0.44",
|
||||
"@anthropic-ai/sdk": "^0.80.0",
|
||||
"@anthropic-ai/vertex-sdk": "^0.14.4",
|
||||
"@anthropic/ink": "workspace:*",
|
||||
"@aws-sdk/client-bedrock": "^3.1020.0",
|
||||
"@aws-sdk/client-bedrock-runtime": "^3.1020.0",
|
||||
"@aws-sdk/client-sts": "^3.1020.0",
|
||||
"@aws-sdk/credential-provider-node": "^3.972.28",
|
||||
"@aws-sdk/credential-providers": "^3.1020.0",
|
||||
"@aws-sdk/client-bedrock": "^3.1032.0",
|
||||
"@aws-sdk/client-bedrock-runtime": "^3.1032.0",
|
||||
"@aws-sdk/client-sts": "^3.1032.0",
|
||||
"@aws-sdk/credential-provider-node": "^3.972.32",
|
||||
"@aws-sdk/credential-providers": "^3.1032.0",
|
||||
"@azure/identity": "^4.13.1",
|
||||
"@biomejs/biome": "^2.4.10",
|
||||
"@biomejs/biome": "^2.4.12",
|
||||
"@claude-code-best/agent-tools": "workspace:*",
|
||||
"@claude-code-best/builtin-tools": "workspace:*",
|
||||
"@claude-code-best/mcp-client": "workspace:*",
|
||||
@@ -41,7 +41,7 @@
|
||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||
"@opentelemetry/api": "^1.9.1",
|
||||
"@opentelemetry/api-logs": "^0.214.0",
|
||||
"@opentelemetry/core": "^2.6.1",
|
||||
"@opentelemetry/core": "^2.7.0",
|
||||
"@opentelemetry/exporter-logs-otlp-grpc": "^0.214.0",
|
||||
"@opentelemetry/exporter-logs-otlp-http": "^0.214.0",
|
||||
"@opentelemetry/exporter-logs-otlp-proto": "^0.214.0",
|
||||
@@ -52,14 +52,14 @@
|
||||
"@opentelemetry/exporter-trace-otlp-grpc": "^0.214.0",
|
||||
"@opentelemetry/exporter-trace-otlp-http": "^0.214.0",
|
||||
"@opentelemetry/exporter-trace-otlp-proto": "^0.214.0",
|
||||
"@opentelemetry/resources": "^2.6.1",
|
||||
"@opentelemetry/resources": "^2.7.0",
|
||||
"@opentelemetry/sdk-logs": "^0.214.0",
|
||||
"@opentelemetry/sdk-metrics": "^2.6.1",
|
||||
"@opentelemetry/sdk-trace-base": "^2.6.1",
|
||||
"@opentelemetry/sdk-metrics": "^2.7.0",
|
||||
"@opentelemetry/sdk-trace-base": "^2.7.0",
|
||||
"@opentelemetry/semantic-conventions": "^1.40.0",
|
||||
"@sentry/node": "^10.47.0",
|
||||
"@smithy/core": "^3.23.13",
|
||||
"@smithy/node-http-handler": "^4.5.1",
|
||||
"@sentry/node": "^10.49.0",
|
||||
"@smithy/core": "^3.23.15",
|
||||
"@smithy/node-http-handler": "^4.5.3",
|
||||
"@types/bun": "^1.3.12",
|
||||
"@types/cacache": "^20.0.1",
|
||||
"@types/he": "^1.2.3",
|
||||
@@ -81,7 +81,7 @@
|
||||
"asciichart": "^1.5.25",
|
||||
"audio-capture-napi": "workspace:*",
|
||||
"auto-bind": "^5.0.1",
|
||||
"axios": "^1.14.0",
|
||||
"axios": "^1.15.0",
|
||||
"bidi-js": "^1.0.3",
|
||||
"cacache": "^20.0.4",
|
||||
"chalk": "^5.6.2",
|
||||
@@ -96,7 +96,7 @@
|
||||
"execa": "^9.6.1",
|
||||
"fflate": "^0.8.2",
|
||||
"figures": "^6.1.0",
|
||||
"fuse.js": "^7.1.0",
|
||||
"fuse.js": "^7.3.0",
|
||||
"get-east-asian-width": "^1.5.0",
|
||||
"google-auth-library": "^10.6.2",
|
||||
"he": "^1.2.0",
|
||||
@@ -106,21 +106,22 @@
|
||||
"image-processor-napi": "workspace:*",
|
||||
"indent-string": "^5.0.0",
|
||||
"jsonc-parser": "^3.3.1",
|
||||
"knip": "^6.1.1",
|
||||
"lodash-es": "^4.17.23",
|
||||
"lru-cache": "^11.2.7",
|
||||
"marked": "^17.0.5",
|
||||
"knip": "^6.4.1",
|
||||
"lodash-es": "^4.18.1",
|
||||
"lru-cache": "^11.3.5",
|
||||
"marked": "^17.0.6",
|
||||
"modifiers-napi": "workspace:*",
|
||||
"openai": "^6.33.0",
|
||||
"openai": "^6.34.0",
|
||||
"p-map": "^7.0.4",
|
||||
"picomatch": "^4.0.4",
|
||||
"plist": "^3.1.0",
|
||||
"proper-lockfile": "^4.1.2",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^19.2.4",
|
||||
"qrcode-terminal": "^0.12.0",
|
||||
"react": "^19.2.5",
|
||||
"react-compiler-runtime": "^1.0.0",
|
||||
"react-reconciler": "^0.33.0",
|
||||
"rollup": "^4.60.1",
|
||||
"rollup": "^4.60.2",
|
||||
"semver": "^7.7.4",
|
||||
"sharp": "^0.34.5",
|
||||
"shell-quote": "^1.8.3",
|
||||
@@ -129,10 +130,10 @@
|
||||
"strip-ansi": "^7.2.0",
|
||||
"supports-hyperlinks": "^4.4.0",
|
||||
"tree-kill": "^1.2.2",
|
||||
"turndown": "^7.2.2",
|
||||
"type-fest": "^5.5.0",
|
||||
"typescript": "^6.0.2",
|
||||
"undici": "^7.24.6",
|
||||
"turndown": "^7.2.4",
|
||||
"type-fest": "^5.6.0",
|
||||
"typescript": "^6.0.3",
|
||||
"undici": "^7.25.0",
|
||||
"url-handler-napi": "workspace:*",
|
||||
"usehooks-ts": "^3.1.1",
|
||||
"vite": "^8.0.8",
|
||||
@@ -2476,6 +2477,8 @@
|
||||
|
||||
"qrcode": ["qrcode@1.5.4", "https://registry.npmmirror.com/qrcode/-/qrcode-1.5.4.tgz", { "dependencies": { "dijkstrajs": "^1.0.1", "pngjs": "^5.0.0", "yargs": "^15.3.1" }, "bin": { "qrcode": "bin/qrcode" } }, "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg=="],
|
||||
|
||||
"qrcode-terminal": ["qrcode-terminal@0.12.0", "", { "bin": { "qrcode-terminal": "./bin/qrcode-terminal.js" } }, "sha512-EXtzRZmC+YGmGlDFbXKxQiMZNwCLEO6BANKXG4iCtSIM0yqc/pappSx3RIKr4r0uh5JsBckOXeKrB3Iz7mdQpQ=="],
|
||||
|
||||
"qs": ["qs@6.15.1", "https://registry.npmmirror.com/qs/-/qs-6.15.1.tgz", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg=="],
|
||||
|
||||
"queue-microtask": ["queue-microtask@1.2.3", "https://registry.npmmirror.com/queue-microtask/-/queue-microtask-1.2.3.tgz", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
|
||||
|
||||
@@ -173,6 +173,7 @@
|
||||
"plist": "^3.1.0",
|
||||
"proper-lockfile": "^4.1.2",
|
||||
"qrcode": "^1.5.4",
|
||||
"qrcode-terminal": "^0.12.0",
|
||||
"react": "^19.2.5",
|
||||
"react-compiler-runtime": "^1.0.0",
|
||||
"react-reconciler": "^0.33.0",
|
||||
|
||||
@@ -140,6 +140,13 @@ async function main(): Promise<void> {
|
||||
return
|
||||
}
|
||||
|
||||
if (args[0] === 'weixin') {
|
||||
profileCheckpoint('cli_weixin_path')
|
||||
const { handleWeixinCli } = await import('../services/weixin/cli.js')
|
||||
await handleWeixinCli(args.slice(1))
|
||||
return
|
||||
}
|
||||
|
||||
// Fast-path for `--daemon-worker=<kind>` (internal — supervisor spawns this).
|
||||
// Must come before the daemon subcommand check: spawned per-worker, so
|
||||
// perf-sensitive. No enableConfigs(), no analytics sinks at this layer —
|
||||
|
||||
54
src/services/weixin/__tests__/accounts.test.ts
Normal file
54
src/services/weixin/__tests__/accounts.test.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { afterEach, describe, expect, test } from 'bun:test'
|
||||
import { mkdtempSync, rmSync, statSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
const testDir = mkdtempSync(join(tmpdir(), 'weixin-test-accounts-'))
|
||||
process.env.WEIXIN_STATE_DIR = testDir
|
||||
|
||||
import { clearAccount, loadAccount, saveAccount } from '../accounts.js'
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(testDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe('account storage', () => {
|
||||
test('loadAccount returns null when no account exists', () => {
|
||||
expect(loadAccount()).toBeNull()
|
||||
})
|
||||
|
||||
test('saveAccount and loadAccount round-trip', () => {
|
||||
const data = {
|
||||
token: 'test-token',
|
||||
baseUrl: 'https://example.com',
|
||||
userId: 'user1',
|
||||
savedAt: '2025-01-01T00:00:00.000Z',
|
||||
}
|
||||
saveAccount(data)
|
||||
expect(loadAccount()).toEqual(data)
|
||||
})
|
||||
|
||||
test('saveAccount sets file permissions to 0600', () => {
|
||||
saveAccount({
|
||||
token: 'test',
|
||||
baseUrl: 'https://example.com',
|
||||
savedAt: new Date().toISOString(),
|
||||
})
|
||||
const stats = statSync(join(testDir, 'account.json'))
|
||||
if (process.platform === 'win32') {
|
||||
expect(stats.isFile()).toBe(true)
|
||||
return
|
||||
}
|
||||
expect(stats.mode & 0o777).toBe(0o600)
|
||||
})
|
||||
|
||||
test('clearAccount removes the file', () => {
|
||||
saveAccount({
|
||||
token: 'test',
|
||||
baseUrl: 'https://example.com',
|
||||
savedAt: new Date().toISOString(),
|
||||
})
|
||||
clearAccount()
|
||||
expect(loadAccount()).toBeNull()
|
||||
})
|
||||
})
|
||||
90
src/services/weixin/__tests__/media.test.ts
Normal file
90
src/services/weixin/__tests__/media.test.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { randomBytes } from 'node:crypto'
|
||||
import {
|
||||
aesEcbPaddedSize,
|
||||
buildCdnDownloadUrl,
|
||||
buildCdnUploadUrl,
|
||||
decryptAesEcb,
|
||||
encryptAesEcb,
|
||||
guessMediaType,
|
||||
parseAesKey,
|
||||
} from '../media.js'
|
||||
import { UploadMediaType } from '../types.js'
|
||||
|
||||
describe('AES-128-ECB', () => {
|
||||
test('encrypt then decrypt returns original data', () => {
|
||||
const key = randomBytes(16)
|
||||
const plaintext = Buffer.from('hello world test data!!')
|
||||
const ciphertext = encryptAesEcb(plaintext, key)
|
||||
expect(decryptAesEcb(ciphertext, key)).toEqual(plaintext)
|
||||
})
|
||||
|
||||
test('different keys produce different ciphertext', () => {
|
||||
const plaintext = Buffer.from('test data')
|
||||
expect(
|
||||
encryptAesEcb(plaintext, randomBytes(16)),
|
||||
).not.toEqual(encryptAesEcb(plaintext, randomBytes(16)))
|
||||
})
|
||||
})
|
||||
|
||||
describe('aesEcbPaddedSize', () => {
|
||||
test('pads to next 16-byte boundary', () => {
|
||||
expect(aesEcbPaddedSize(1)).toBe(16)
|
||||
expect(aesEcbPaddedSize(16)).toBe(32)
|
||||
expect(aesEcbPaddedSize(17)).toBe(32)
|
||||
expect(aesEcbPaddedSize(32)).toBe(48)
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseAesKey', () => {
|
||||
test('parses 16 raw bytes from base64', () => {
|
||||
const raw = randomBytes(16)
|
||||
expect(parseAesKey(raw.toString('base64'))).toEqual(raw)
|
||||
})
|
||||
|
||||
test('parses hex-encoded key from base64', () => {
|
||||
const raw = randomBytes(16)
|
||||
const b64 = Buffer.from(raw.toString('hex'), 'ascii').toString('base64')
|
||||
expect(parseAesKey(b64)).toEqual(raw)
|
||||
})
|
||||
|
||||
test('throws on invalid key length', () => {
|
||||
expect(() => parseAesKey(Buffer.from('short').toString('base64'))).toThrow(
|
||||
'Invalid aes_key',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('CDN URL builders', () => {
|
||||
test('buildCdnDownloadUrl encodes param', () => {
|
||||
expect(buildCdnDownloadUrl('abc=123', 'https://cdn.example.com')).toBe(
|
||||
'https://cdn.example.com/download?encrypted_query_param=abc%3D123',
|
||||
)
|
||||
})
|
||||
|
||||
test('buildCdnUploadUrl encodes params', () => {
|
||||
expect(
|
||||
buildCdnUploadUrl('https://cdn.example.com', 'param1', 'key1'),
|
||||
).toBe(
|
||||
'https://cdn.example.com/upload?encrypted_query_param=param1&filekey=key1',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('guessMediaType', () => {
|
||||
test('detects image extensions', () => {
|
||||
expect(guessMediaType('photo.jpg')).toBe(UploadMediaType.IMAGE)
|
||||
expect(guessMediaType('photo.png')).toBe(UploadMediaType.IMAGE)
|
||||
expect(guessMediaType('photo.webp')).toBe(UploadMediaType.IMAGE)
|
||||
})
|
||||
|
||||
test('detects video extensions', () => {
|
||||
expect(guessMediaType('video.mp4')).toBe(UploadMediaType.VIDEO)
|
||||
expect(guessMediaType('video.mov')).toBe(UploadMediaType.VIDEO)
|
||||
})
|
||||
|
||||
test('defaults to FILE for unknown extensions', () => {
|
||||
expect(guessMediaType('doc.pdf')).toBe(UploadMediaType.FILE)
|
||||
expect(guessMediaType('archive.zip')).toBe(UploadMediaType.FILE)
|
||||
})
|
||||
})
|
||||
22
src/services/weixin/__tests__/monitor.test.ts
Normal file
22
src/services/weixin/__tests__/monitor.test.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { extractPermissionReply } from '../monitor.js'
|
||||
|
||||
describe('extractPermissionReply', () => {
|
||||
test('parses allow replies', () => {
|
||||
expect(extractPermissionReply('yes abcde')).toEqual({
|
||||
requestId: 'abcde',
|
||||
behavior: 'allow',
|
||||
})
|
||||
})
|
||||
|
||||
test('parses deny replies', () => {
|
||||
expect(extractPermissionReply('No abcde')).toEqual({
|
||||
requestId: 'abcde',
|
||||
behavior: 'deny',
|
||||
})
|
||||
})
|
||||
|
||||
test('ignores unrelated text', () => {
|
||||
expect(extractPermissionReply('yes please do it')).toBeNull()
|
||||
})
|
||||
})
|
||||
78
src/services/weixin/__tests__/pairing.test.ts
Normal file
78
src/services/weixin/__tests__/pairing.test.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { afterEach, describe, expect, test } from 'bun:test'
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
const testDir = mkdtempSync(join(tmpdir(), 'weixin-test-pairing-'))
|
||||
process.env.WEIXIN_STATE_DIR = testDir
|
||||
|
||||
import {
|
||||
addPendingPairing,
|
||||
confirmPairing,
|
||||
isAllowed,
|
||||
loadAccessConfig,
|
||||
saveAccessConfig,
|
||||
} from '../pairing.js'
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(testDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe('loadAccessConfig', () => {
|
||||
test('returns default config when no file exists', () => {
|
||||
const config = loadAccessConfig()
|
||||
expect(config.policy).toBe('pairing')
|
||||
expect(config.allowFrom).toEqual([])
|
||||
})
|
||||
|
||||
test('round-trips saved config', () => {
|
||||
saveAccessConfig({ policy: 'allowlist', allowFrom: ['user1'] })
|
||||
const config = loadAccessConfig()
|
||||
expect(config.policy).toBe('allowlist')
|
||||
expect(config.allowFrom).toEqual(['user1'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('isAllowed', () => {
|
||||
test('returns false for unknown user under pairing policy', () => {
|
||||
expect(isAllowed('unknown')).toBe(false)
|
||||
})
|
||||
|
||||
test('returns true for allowed user', () => {
|
||||
saveAccessConfig({ policy: 'pairing', allowFrom: ['user1'] })
|
||||
expect(isAllowed('user1')).toBe(true)
|
||||
})
|
||||
|
||||
test('returns true for any user under disabled policy', () => {
|
||||
saveAccessConfig({ policy: 'disabled', allowFrom: [] })
|
||||
expect(isAllowed('anyone')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('pairing flow', () => {
|
||||
test('generates 6-digit code', () => {
|
||||
expect(addPendingPairing('user1')).toMatch(/^\d{6}$/)
|
||||
})
|
||||
|
||||
test('returns same code for same user', () => {
|
||||
const code1 = addPendingPairing('user1')
|
||||
const code2 = addPendingPairing('user1')
|
||||
expect(code1).toBe(code2)
|
||||
})
|
||||
|
||||
test('confirm adds user to allowlist', () => {
|
||||
const code = addPendingPairing('user1')
|
||||
expect(confirmPairing(code)).toBe('user1')
|
||||
expect(isAllowed('user1')).toBe(true)
|
||||
})
|
||||
|
||||
test('confirm returns null for invalid code', () => {
|
||||
expect(confirmPairing('000000')).toBeNull()
|
||||
})
|
||||
|
||||
test('code cannot be reused after confirmation', () => {
|
||||
const code = addPendingPairing('user1')
|
||||
confirmPairing(code)
|
||||
expect(confirmPairing(code)).toBeNull()
|
||||
})
|
||||
})
|
||||
43
src/services/weixin/__tests__/permissions.test.ts
Normal file
43
src/services/weixin/__tests__/permissions.test.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { afterEach, describe, expect, test } from 'bun:test'
|
||||
import {
|
||||
clearPermissionStateForTests,
|
||||
consumePendingPermission,
|
||||
getActivePermissionChat,
|
||||
savePendingPermission,
|
||||
setActivePermissionChat,
|
||||
} from '../permissions.js'
|
||||
|
||||
afterEach(() => {
|
||||
clearPermissionStateForTests()
|
||||
})
|
||||
|
||||
describe('permission state', () => {
|
||||
test('tracks active permission chat', () => {
|
||||
setActivePermissionChat('user-1', 'ctx-1')
|
||||
expect(getActivePermissionChat()).toEqual({
|
||||
chatId: 'user-1',
|
||||
contextToken: 'ctx-1',
|
||||
updatedAt: expect.any(Number),
|
||||
})
|
||||
})
|
||||
|
||||
test('consumes pending permission only for matching user', () => {
|
||||
savePendingPermission(
|
||||
{
|
||||
request_id: 'abcde',
|
||||
tool_name: 'Bash',
|
||||
description: 'Run a command',
|
||||
input_preview: '{"command":"pwd"}',
|
||||
},
|
||||
'user-1',
|
||||
'ctx-1',
|
||||
)
|
||||
|
||||
expect(consumePendingPermission('abcde', 'user-2')).toBeNull()
|
||||
expect(consumePendingPermission('ABCDE', 'user-1')).toMatchObject({
|
||||
request_id: 'abcde',
|
||||
chatId: 'user-1',
|
||||
})
|
||||
expect(consumePendingPermission('abcde', 'user-1')).toBeNull()
|
||||
})
|
||||
})
|
||||
32
src/services/weixin/__tests__/send.test.ts
Normal file
32
src/services/weixin/__tests__/send.test.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { markdownToPlainText } from '../send.js'
|
||||
|
||||
describe('markdownToPlainText', () => {
|
||||
test('removes bold markers', () => {
|
||||
expect(markdownToPlainText('**bold**')).toBe('bold')
|
||||
})
|
||||
|
||||
test('removes italic markers', () => {
|
||||
expect(markdownToPlainText('*italic*')).toBe('italic')
|
||||
})
|
||||
|
||||
test('removes inline code backticks', () => {
|
||||
expect(markdownToPlainText('`code`')).toBe('code')
|
||||
})
|
||||
|
||||
test('removes code block fences', () => {
|
||||
expect(markdownToPlainText("```js\nconsole.log('hi');\n```"))
|
||||
.toBe("console.log('hi');")
|
||||
})
|
||||
|
||||
test('converts links to text with URL', () => {
|
||||
expect(markdownToPlainText('[click](https://example.com)')).toBe(
|
||||
'click (https://example.com)',
|
||||
)
|
||||
})
|
||||
|
||||
test('handles mixed markdown', () => {
|
||||
expect(markdownToPlainText('# Hello\n\n**bold** and *italic* with `code`'))
|
||||
.toBe('Hello\n\nbold and italic with code')
|
||||
})
|
||||
})
|
||||
57
src/services/weixin/accounts.ts
Normal file
57
src/services/weixin/accounts.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import {
|
||||
chmodSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
unlinkSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs'
|
||||
import { homedir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
export const DEFAULT_BASE_URL = 'https://ilinkai.weixin.qq.com'
|
||||
export const CDN_BASE_URL = 'https://novac2c.cdn.weixin.qq.com/c2c'
|
||||
|
||||
export interface AccountData {
|
||||
token: string
|
||||
baseUrl: string
|
||||
userId?: string
|
||||
savedAt: string
|
||||
}
|
||||
|
||||
export function getStateDir(): string {
|
||||
const dir =
|
||||
process.env.WEIXIN_STATE_DIR ||
|
||||
join(homedir(), '.claude', 'channels', 'weixin')
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true })
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
function accountPath(): string {
|
||||
return join(getStateDir(), 'account.json')
|
||||
}
|
||||
|
||||
export function loadAccount(): AccountData | null {
|
||||
const path = accountPath()
|
||||
if (!existsSync(path)) return null
|
||||
try {
|
||||
return JSON.parse(readFileSync(path, 'utf-8')) as AccountData
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function saveAccount(data: AccountData): void {
|
||||
const path = accountPath()
|
||||
writeFileSync(path, JSON.stringify(data, null, 2), 'utf-8')
|
||||
chmodSync(path, 0o600)
|
||||
}
|
||||
|
||||
export function clearAccount(): void {
|
||||
const path = accountPath()
|
||||
if (existsSync(path)) {
|
||||
unlinkSync(path)
|
||||
}
|
||||
}
|
||||
148
src/services/weixin/api.ts
Normal file
148
src/services/weixin/api.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import { randomBytes } from 'node:crypto'
|
||||
import type {
|
||||
BaseInfo,
|
||||
GetConfigResp,
|
||||
GetUpdatesReq,
|
||||
GetUpdatesResp,
|
||||
GetUploadUrlReq,
|
||||
GetUploadUrlResp,
|
||||
SendMessageReq,
|
||||
SendTypingReq,
|
||||
SendTypingResp,
|
||||
} from './types.js'
|
||||
|
||||
const CHANNEL_VERSION = '0.1.0'
|
||||
|
||||
function baseInfo(): BaseInfo {
|
||||
return { channel_version: CHANNEL_VERSION }
|
||||
}
|
||||
|
||||
function randomUin(): string {
|
||||
return randomBytes(4).toString('base64')
|
||||
}
|
||||
|
||||
function buildHeaders(token?: string): Record<string, string> {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
'X-WECHAT-UIN': randomUin(),
|
||||
}
|
||||
if (token) {
|
||||
headers.AuthorizationType = 'ilink_bot_token'
|
||||
headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
return headers
|
||||
}
|
||||
|
||||
async function post<T>(
|
||||
baseUrl: string,
|
||||
path: string,
|
||||
body: unknown,
|
||||
token?: string,
|
||||
timeoutMs = 40_000,
|
||||
signal?: AbortSignal,
|
||||
): Promise<T> {
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => controller.abort(), timeoutMs)
|
||||
|
||||
if (signal) {
|
||||
signal.addEventListener('abort', () => controller.abort(), { once: true })
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}${path}`, {
|
||||
method: 'POST',
|
||||
headers: buildHeaders(token),
|
||||
body: JSON.stringify(body),
|
||||
signal: controller.signal,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
|
||||
}
|
||||
|
||||
return (await response.json()) as T
|
||||
} finally {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
}
|
||||
|
||||
export async function getUpdates(
|
||||
baseUrl: string,
|
||||
token: string,
|
||||
getUpdatesBuf: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<GetUpdatesResp> {
|
||||
const body: GetUpdatesReq = {
|
||||
get_updates_buf: getUpdatesBuf,
|
||||
base_info: baseInfo(),
|
||||
}
|
||||
|
||||
try {
|
||||
return await post<GetUpdatesResp>(
|
||||
baseUrl,
|
||||
'/ilink/bot/getupdates',
|
||||
body,
|
||||
token,
|
||||
40_000,
|
||||
signal,
|
||||
)
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
return { ret: 0, msgs: [], get_updates_buf: getUpdatesBuf }
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendMessage(
|
||||
baseUrl: string,
|
||||
token: string,
|
||||
msg: SendMessageReq['msg'],
|
||||
): Promise<void> {
|
||||
const body: SendMessageReq = { msg, base_info: baseInfo() }
|
||||
await post(baseUrl, '/ilink/bot/sendmessage', body, token)
|
||||
}
|
||||
|
||||
export async function getUploadUrl(
|
||||
baseUrl: string,
|
||||
token: string,
|
||||
params: Omit<GetUploadUrlReq, 'base_info'>,
|
||||
): Promise<GetUploadUrlResp> {
|
||||
return post<GetUploadUrlResp>(
|
||||
baseUrl,
|
||||
'/ilink/bot/getuploadurl',
|
||||
{ ...params, base_info: baseInfo() },
|
||||
token,
|
||||
)
|
||||
}
|
||||
|
||||
export async function getConfig(
|
||||
baseUrl: string,
|
||||
token: string,
|
||||
userId: string,
|
||||
contextToken?: string,
|
||||
): Promise<GetConfigResp> {
|
||||
return post<GetConfigResp>(
|
||||
baseUrl,
|
||||
'/ilink/bot/getconfig',
|
||||
{
|
||||
ilink_user_id: userId,
|
||||
context_token: contextToken,
|
||||
base_info: baseInfo(),
|
||||
},
|
||||
token,
|
||||
)
|
||||
}
|
||||
|
||||
export async function sendTyping(
|
||||
baseUrl: string,
|
||||
token: string,
|
||||
req: Omit<SendTypingReq, 'base_info'>,
|
||||
): Promise<SendTypingResp> {
|
||||
return post<SendTypingResp>(
|
||||
baseUrl,
|
||||
'/ilink/bot/sendtyping',
|
||||
{ ...req, base_info: baseInfo() },
|
||||
token,
|
||||
)
|
||||
}
|
||||
110
src/services/weixin/cli.ts
Normal file
110
src/services/weixin/cli.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { clearAccount, DEFAULT_BASE_URL, loadAccount, saveAccount } from './accounts.js'
|
||||
import { startLogin, waitForLogin } from './login.js'
|
||||
import { confirmPairing } from './pairing.js'
|
||||
import { runWeixinMcpServer } from './server.js'
|
||||
|
||||
function printUsage(): void {
|
||||
process.stdout.write(
|
||||
[
|
||||
'Usage:',
|
||||
' ccb weixin serve',
|
||||
' ccb weixin login',
|
||||
' ccb weixin login clear',
|
||||
' ccb weixin access pair <code>',
|
||||
'',
|
||||
'Session enablement:',
|
||||
' ccb --channels plugin:weixin@builtin',
|
||||
].join('\n') + '\n',
|
||||
)
|
||||
}
|
||||
|
||||
async function runLogin(clear = false): Promise<void> {
|
||||
if (clear) {
|
||||
clearAccount()
|
||||
process.stdout.write('WeChat account cleared.\n')
|
||||
return
|
||||
}
|
||||
|
||||
const existing = loadAccount()
|
||||
if (existing) {
|
||||
process.stdout.write(
|
||||
[
|
||||
'Already connected:',
|
||||
` User ID: ${existing.userId || 'unknown'}`,
|
||||
` Connected since: ${existing.savedAt}`,
|
||||
'',
|
||||
'Run `ccb weixin login clear` to disconnect.',
|
||||
'Restart Claude Code with:',
|
||||
' ccb --channels plugin:weixin@builtin',
|
||||
].join('\n') + '\n',
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
process.stdout.write('Starting WeChat QR login...\n\n')
|
||||
const qr = await startLogin(DEFAULT_BASE_URL)
|
||||
process.stdout.write(
|
||||
`\nScan the QR code above with WeChat, or open this URL:\n${qr.qrcodeUrl || ''}\n\n`,
|
||||
)
|
||||
|
||||
const result = await waitForLogin({
|
||||
qrcodeId: qr.qrcodeId,
|
||||
apiBaseUrl: DEFAULT_BASE_URL,
|
||||
})
|
||||
|
||||
if (!result.connected || !result.token) {
|
||||
process.stderr.write(`Login failed: ${result.message}\n`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
saveAccount({
|
||||
token: result.token,
|
||||
baseUrl: result.baseUrl || DEFAULT_BASE_URL,
|
||||
userId: result.userId,
|
||||
savedAt: new Date().toISOString(),
|
||||
})
|
||||
|
||||
process.stdout.write(
|
||||
[
|
||||
'Connected successfully!',
|
||||
` User ID: ${result.userId || 'unknown'}`,
|
||||
` Base URL: ${result.baseUrl || DEFAULT_BASE_URL}`,
|
||||
'',
|
||||
'Restart Claude Code with:',
|
||||
' ccb --channels plugin:weixin@builtin',
|
||||
].join('\n') + '\n',
|
||||
)
|
||||
}
|
||||
|
||||
function runAccess(args: string[]): void {
|
||||
if (args[0] !== 'pair' || !args[1]) {
|
||||
printUsage()
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const userId = confirmPairing(args[1])
|
||||
if (!userId) {
|
||||
process.stderr.write('Invalid or expired pairing code.\n')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
process.stdout.write(`Paired successfully: ${userId}\n`)
|
||||
}
|
||||
|
||||
export async function handleWeixinCli(args: string[]): Promise<void> {
|
||||
const [subcommand, ...rest] = args
|
||||
|
||||
switch (subcommand) {
|
||||
case 'serve':
|
||||
await runWeixinMcpServer()
|
||||
return
|
||||
case 'login':
|
||||
await runLogin(rest[0] === 'clear')
|
||||
return
|
||||
case 'access':
|
||||
runAccess(rest)
|
||||
return
|
||||
default:
|
||||
printUsage()
|
||||
}
|
||||
}
|
||||
127
src/services/weixin/login.ts
Normal file
127
src/services/weixin/login.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import qrcode from 'qrcode-terminal'
|
||||
|
||||
export interface QRCodeResult {
|
||||
qrcodeUrl?: string
|
||||
qrcodeId: string
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface LoginResult {
|
||||
connected: boolean
|
||||
token?: string
|
||||
accountId?: string
|
||||
baseUrl?: string
|
||||
userId?: string
|
||||
message: string
|
||||
}
|
||||
|
||||
export async function startLogin(apiBaseUrl: string): Promise<QRCodeResult> {
|
||||
const response = await fetch(`${apiBaseUrl}/ilink/bot/get_bot_qrcode?bot_type=3`)
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to get QR code: HTTP ${response.status}`)
|
||||
}
|
||||
|
||||
const data = (await response.json()) as {
|
||||
qrcode?: string
|
||||
qrcode_img_content?: string
|
||||
}
|
||||
|
||||
if (!data.qrcode) {
|
||||
throw new Error('No qrcode in response')
|
||||
}
|
||||
|
||||
const qrcodeUrl = data.qrcode_img_content || ''
|
||||
if (qrcodeUrl) {
|
||||
qrcode.generate(qrcodeUrl, { small: true }, output => {
|
||||
process.stderr.write(`${output}\n`)
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
qrcodeUrl,
|
||||
qrcodeId: data.qrcode,
|
||||
message: 'Scan the QR code with WeChat to connect.',
|
||||
}
|
||||
}
|
||||
|
||||
export async function waitForLogin(params: {
|
||||
qrcodeId: string
|
||||
apiBaseUrl: string
|
||||
timeoutMs?: number
|
||||
maxRetries?: number
|
||||
}): Promise<LoginResult> {
|
||||
const { qrcodeId, apiBaseUrl, timeoutMs = 480_000, maxRetries = 3 } = params
|
||||
const deadline = Date.now() + timeoutMs
|
||||
let currentQrcodeId = qrcodeId
|
||||
let retryCount = 0
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => controller.abort(), 60_000)
|
||||
|
||||
const response = await fetch(
|
||||
`${apiBaseUrl}/ilink/bot/get_qrcode_status?qrcode=${encodeURIComponent(currentQrcodeId)}`,
|
||||
{
|
||||
headers: { 'iLink-App-ClientVersion': '1' },
|
||||
signal: controller.signal,
|
||||
},
|
||||
)
|
||||
clearTimeout(timeout)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`)
|
||||
}
|
||||
|
||||
const data = (await response.json()) as {
|
||||
status?: string
|
||||
bot_token?: string
|
||||
ilink_bot_id?: string
|
||||
baseurl?: string
|
||||
ilink_user_id?: string
|
||||
}
|
||||
|
||||
switch (data.status) {
|
||||
case 'confirmed':
|
||||
return {
|
||||
connected: true,
|
||||
token: data.bot_token,
|
||||
accountId: data.ilink_bot_id,
|
||||
baseUrl: data.baseurl,
|
||||
userId: data.ilink_user_id,
|
||||
message: 'Connected to WeChat successfully!',
|
||||
}
|
||||
case 'scaned':
|
||||
process.stderr.write(
|
||||
'QR code scanned, waiting for confirmation...\n',
|
||||
)
|
||||
break
|
||||
case 'expired': {
|
||||
retryCount += 1
|
||||
if (retryCount >= maxRetries) {
|
||||
return {
|
||||
connected: false,
|
||||
message: 'QR code expired after maximum retries.',
|
||||
}
|
||||
}
|
||||
process.stderr.write('QR code expired, refreshing...\n')
|
||||
const refreshed = await startLogin(apiBaseUrl)
|
||||
currentQrcodeId = refreshed.qrcodeId
|
||||
break
|
||||
}
|
||||
case 'wait':
|
||||
default:
|
||||
break
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
continue
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||
}
|
||||
|
||||
return { connected: false, message: 'Login timed out.' }
|
||||
}
|
||||
163
src/services/weixin/media.ts
Normal file
163
src/services/weixin/media.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import {
|
||||
createCipheriv,
|
||||
createDecipheriv,
|
||||
createHash,
|
||||
randomBytes,
|
||||
} from 'node:crypto'
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { basename, extname, join } from 'node:path'
|
||||
import { getUploadUrl } from './api.js'
|
||||
import { UploadMediaType } from './types.js'
|
||||
|
||||
export function encryptAesEcb(plaintext: Buffer, key: Buffer): Buffer {
|
||||
const cipher = createCipheriv('aes-128-ecb', key, null)
|
||||
return Buffer.concat([cipher.update(plaintext), cipher.final()])
|
||||
}
|
||||
|
||||
export function decryptAesEcb(ciphertext: Buffer, key: Buffer): Buffer {
|
||||
const decipher = createDecipheriv('aes-128-ecb', key, null)
|
||||
return Buffer.concat([decipher.update(ciphertext), decipher.final()])
|
||||
}
|
||||
|
||||
export function aesEcbPaddedSize(size: number): number {
|
||||
return size + (16 - (size % 16))
|
||||
}
|
||||
|
||||
export function buildCdnDownloadUrl(
|
||||
encryptedQueryParam: string,
|
||||
cdnBaseUrl: string,
|
||||
): string {
|
||||
return `${cdnBaseUrl}/download?encrypted_query_param=${encodeURIComponent(encryptedQueryParam)}`
|
||||
}
|
||||
|
||||
export function buildCdnUploadUrl(
|
||||
cdnBaseUrl: string,
|
||||
uploadParam: string,
|
||||
filekey: string,
|
||||
): string {
|
||||
return `${cdnBaseUrl}/upload?encrypted_query_param=${encodeURIComponent(uploadParam)}&filekey=${encodeURIComponent(filekey)}`
|
||||
}
|
||||
|
||||
export function parseAesKey(aesKeyBase64: string): Buffer {
|
||||
const decoded = Buffer.from(aesKeyBase64, 'base64')
|
||||
if (decoded.length === 16) {
|
||||
return decoded
|
||||
}
|
||||
if (decoded.length === 32 && /^[0-9a-fA-F]{32}$/.test(decoded.toString('ascii'))) {
|
||||
return Buffer.from(decoded.toString('ascii'), 'hex')
|
||||
}
|
||||
throw new Error(
|
||||
`Invalid aes_key: expected 16 raw bytes or 32 hex chars, got ${decoded.length} bytes`,
|
||||
)
|
||||
}
|
||||
|
||||
export async function downloadAndDecrypt(params: {
|
||||
encryptQueryParam: string
|
||||
aesKey: string
|
||||
cdnBaseUrl: string
|
||||
}): Promise<Buffer> {
|
||||
const url = buildCdnDownloadUrl(params.encryptQueryParam, params.cdnBaseUrl)
|
||||
const response = await fetch(url)
|
||||
if (!response.ok) {
|
||||
throw new Error(`CDN download failed: HTTP ${response.status}`)
|
||||
}
|
||||
const ciphertext = Buffer.from(await response.arrayBuffer())
|
||||
return decryptAesEcb(ciphertext, parseAesKey(params.aesKey))
|
||||
}
|
||||
|
||||
export interface UploadedFileInfo {
|
||||
encryptQueryParam: string
|
||||
aesKey: string
|
||||
fileSize: number
|
||||
rawSize: number
|
||||
fileName: string
|
||||
}
|
||||
|
||||
export async function uploadFile(params: {
|
||||
filePath: string
|
||||
toUserId: string
|
||||
mediaType: number
|
||||
apiBaseUrl: string
|
||||
token: string
|
||||
cdnBaseUrl: string
|
||||
}): Promise<UploadedFileInfo> {
|
||||
const plaintext = readFileSync(params.filePath)
|
||||
const rawSize = plaintext.length
|
||||
const rawMd5 = createHash('md5').update(plaintext).digest('hex')
|
||||
const aesKey = randomBytes(16)
|
||||
const filekey = randomBytes(16).toString('hex')
|
||||
const ciphertext = encryptAesEcb(plaintext, aesKey)
|
||||
const fileSize = ciphertext.length
|
||||
|
||||
const uploadResp = await getUploadUrl(params.apiBaseUrl, params.token, {
|
||||
filekey,
|
||||
media_type: params.mediaType,
|
||||
to_user_id: params.toUserId,
|
||||
rawsize: rawSize,
|
||||
rawfilemd5: rawMd5,
|
||||
filesize: fileSize,
|
||||
no_need_thumb: true,
|
||||
aeskey: aesKey.toString('hex'),
|
||||
})
|
||||
|
||||
if (!uploadResp.upload_param) {
|
||||
throw new Error('No upload_param in response')
|
||||
}
|
||||
|
||||
const uploadUrl = buildCdnUploadUrl(
|
||||
params.cdnBaseUrl,
|
||||
uploadResp.upload_param,
|
||||
filekey,
|
||||
)
|
||||
const uploadResult = await fetch(uploadUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/octet-stream' },
|
||||
body: new Uint8Array(ciphertext),
|
||||
})
|
||||
|
||||
if (!uploadResult.ok) {
|
||||
throw new Error(`CDN upload failed: HTTP ${uploadResult.status}`)
|
||||
}
|
||||
|
||||
return {
|
||||
encryptQueryParam: uploadResult.headers.get('x-encrypted-param') || '',
|
||||
aesKey: Buffer.from(aesKey.toString('hex')).toString('base64'),
|
||||
fileSize,
|
||||
rawSize,
|
||||
fileName: basename(params.filePath),
|
||||
}
|
||||
}
|
||||
|
||||
export function guessMediaType(filePath: string): number {
|
||||
const ext = extname(filePath).toLowerCase()
|
||||
const imageExts = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.heic']
|
||||
const videoExts = ['.mp4', '.mov', '.avi', '.mkv', '.webm']
|
||||
|
||||
if (imageExts.includes(ext)) return UploadMediaType.IMAGE
|
||||
if (videoExts.includes(ext)) return UploadMediaType.VIDEO
|
||||
return UploadMediaType.FILE
|
||||
}
|
||||
|
||||
export async function downloadRemoteToTemp(
|
||||
url: string,
|
||||
destDir?: string,
|
||||
): Promise<string> {
|
||||
const dir = destDir || join(tmpdir(), 'weixin-downloads')
|
||||
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
|
||||
|
||||
const response = await fetch(url)
|
||||
if (!response.ok) throw new Error(`Download failed: HTTP ${response.status}`)
|
||||
|
||||
const buffer = Buffer.from(await response.arrayBuffer())
|
||||
const urlPath = new URL(url).pathname
|
||||
const name = basename(urlPath) || `file_${Date.now()}`
|
||||
const dest = join(dir, name)
|
||||
writeFileSync(dest, buffer)
|
||||
return dest
|
||||
}
|
||||
302
src/services/weixin/monitor.ts
Normal file
302
src/services/weixin/monitor.ts
Normal file
@@ -0,0 +1,302 @@
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { basename, join } from 'node:path'
|
||||
import { PERMISSION_REPLY_RE } from '../mcp/channelPermissions.js'
|
||||
import { getUpdates } from './api.js'
|
||||
import { getStateDir } from './accounts.js'
|
||||
import { downloadAndDecrypt } from './media.js'
|
||||
import { addPendingPairing, isAllowed } from './pairing.js'
|
||||
import { consumePendingPermission, setActivePermissionChat } from './permissions.js'
|
||||
import { sendText } from './send.js'
|
||||
import { MessageItemType, MessageType, type MessageItem, type WeixinMessage } from './types.js'
|
||||
|
||||
const contextTokens = new Map<string, string>()
|
||||
|
||||
export function getContextToken(userId: string): string | undefined {
|
||||
return contextTokens.get(userId)
|
||||
}
|
||||
|
||||
function cursorPath(): string {
|
||||
return join(getStateDir(), 'cursor.txt')
|
||||
}
|
||||
|
||||
function loadCursor(): string {
|
||||
const path = cursorPath()
|
||||
if (existsSync(path)) return readFileSync(path, 'utf-8').trim()
|
||||
return ''
|
||||
}
|
||||
|
||||
function saveCursor(cursor: string): void {
|
||||
writeFileSync(cursorPath(), cursor, 'utf-8')
|
||||
}
|
||||
|
||||
async function downloadMedia(
|
||||
item: MessageItem,
|
||||
cdnBaseUrl: string,
|
||||
): Promise<{ path: string; type: string } | null> {
|
||||
let encryptQueryParam: string | undefined
|
||||
let aesKey: string | undefined
|
||||
let ext = ''
|
||||
let mediaType = ''
|
||||
|
||||
switch (item.type) {
|
||||
case MessageItemType.IMAGE:
|
||||
encryptQueryParam = item.image_item?.media?.encrypt_query_param
|
||||
aesKey = item.image_item?.aeskey
|
||||
? Buffer.from(item.image_item.aeskey, 'hex').toString('base64')
|
||||
: item.image_item?.media?.aes_key
|
||||
ext = '.jpg'
|
||||
mediaType = 'image'
|
||||
break
|
||||
case MessageItemType.VOICE:
|
||||
encryptQueryParam = item.voice_item?.media?.encrypt_query_param
|
||||
aesKey = item.voice_item?.media?.aes_key
|
||||
ext = '.silk'
|
||||
mediaType = 'voice'
|
||||
break
|
||||
case MessageItemType.FILE:
|
||||
encryptQueryParam = item.file_item?.media?.encrypt_query_param
|
||||
aesKey = item.file_item?.media?.aes_key
|
||||
ext = item.file_item?.file_name
|
||||
? `.${item.file_item.file_name.split('.').pop()}`
|
||||
: ''
|
||||
mediaType = 'file'
|
||||
break
|
||||
case MessageItemType.VIDEO:
|
||||
encryptQueryParam = item.video_item?.media?.encrypt_query_param
|
||||
aesKey = item.video_item?.media?.aes_key
|
||||
ext = '.mp4'
|
||||
mediaType = 'video'
|
||||
break
|
||||
default:
|
||||
return null
|
||||
}
|
||||
|
||||
if (!encryptQueryParam || !aesKey) return null
|
||||
|
||||
try {
|
||||
const data = await downloadAndDecrypt({
|
||||
encryptQueryParam,
|
||||
aesKey,
|
||||
cdnBaseUrl,
|
||||
})
|
||||
const dir = join(tmpdir(), 'weixin-media')
|
||||
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
|
||||
const rawFileName = item.file_item?.file_name || `${Date.now()}${ext}`
|
||||
const fileName = basename(rawFileName)
|
||||
const filePath = join(dir, fileName)
|
||||
writeFileSync(filePath, data)
|
||||
return { path: filePath, type: mediaType }
|
||||
} catch (error) {
|
||||
process.stderr.write(`[weixin] Failed to download media: ${error}\n`)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export interface ParsedMessage {
|
||||
fromUserId: string
|
||||
messageId: string
|
||||
text: string
|
||||
attachmentPath?: string
|
||||
attachmentType?: string
|
||||
}
|
||||
|
||||
export type OnMessageCallback = (msg: ParsedMessage) => Promise<void>
|
||||
|
||||
export type PermissionResponse = {
|
||||
requestId: string
|
||||
behavior: 'allow' | 'deny'
|
||||
fromUserId: string
|
||||
}
|
||||
|
||||
export type OnPermissionResponseCallback = (
|
||||
response: PermissionResponse,
|
||||
) => Promise<void>
|
||||
|
||||
export function extractPermissionReply(
|
||||
text: string,
|
||||
): { requestId: string; behavior: 'allow' | 'deny' } | null {
|
||||
const match = text.match(PERMISSION_REPLY_RE)
|
||||
if (!match) return null
|
||||
const behavior =
|
||||
match[1]?.toLowerCase().startsWith('y') ? 'allow' : 'deny'
|
||||
const requestId = match[2]?.toLowerCase()
|
||||
if (!requestId) return null
|
||||
return { requestId, behavior }
|
||||
}
|
||||
|
||||
export async function startPollLoop(params: {
|
||||
baseUrl: string
|
||||
cdnBaseUrl: string
|
||||
token: string
|
||||
onMessage: OnMessageCallback
|
||||
onPermissionResponse?: OnPermissionResponseCallback
|
||||
abortSignal: AbortSignal
|
||||
}): Promise<void> {
|
||||
const {
|
||||
baseUrl,
|
||||
cdnBaseUrl,
|
||||
token,
|
||||
onMessage,
|
||||
onPermissionResponse,
|
||||
abortSignal,
|
||||
} = params
|
||||
let cursor = loadCursor()
|
||||
let consecutiveErrors = 0
|
||||
|
||||
process.stderr.write('[weixin] Starting message poll loop...\n')
|
||||
|
||||
while (!abortSignal.aborted) {
|
||||
try {
|
||||
const response = await getUpdates(baseUrl, token, cursor, abortSignal)
|
||||
|
||||
if (response.errcode === -14) {
|
||||
process.stderr.write(
|
||||
'[weixin] Session expired (errcode -14). Pausing for 30s...\n',
|
||||
)
|
||||
await new Promise(resolve => setTimeout(resolve, 30_000))
|
||||
continue
|
||||
}
|
||||
|
||||
if (response.ret !== 0 && response.ret !== undefined) {
|
||||
throw new Error(
|
||||
`getUpdates error: ret=${response.ret} errcode=${response.errcode} ${response.errmsg}`,
|
||||
)
|
||||
}
|
||||
|
||||
consecutiveErrors = 0
|
||||
|
||||
if (response.get_updates_buf) {
|
||||
cursor = response.get_updates_buf
|
||||
saveCursor(cursor)
|
||||
}
|
||||
|
||||
if (response.msgs && response.msgs.length > 0) {
|
||||
for (const msg of response.msgs) {
|
||||
await processMessage(msg, {
|
||||
baseUrl,
|
||||
cdnBaseUrl,
|
||||
token,
|
||||
onMessage,
|
||||
onPermissionResponse,
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (abortSignal.aborted) break
|
||||
|
||||
consecutiveErrors += 1
|
||||
process.stderr.write(
|
||||
`[weixin] Poll error (${consecutiveErrors}): ${error instanceof Error ? error.message : String(error)}\n`,
|
||||
)
|
||||
|
||||
if (consecutiveErrors >= 3) {
|
||||
process.stderr.write(
|
||||
'[weixin] Too many consecutive errors, backing off 30s...\n',
|
||||
)
|
||||
await new Promise(resolve => setTimeout(resolve, 30_000))
|
||||
consecutiveErrors = 0
|
||||
} else {
|
||||
await new Promise(resolve => setTimeout(resolve, 2000))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
process.stderr.write('[weixin] Poll loop stopped.\n')
|
||||
}
|
||||
|
||||
async function processMessage(
|
||||
msg: WeixinMessage,
|
||||
ctx: {
|
||||
baseUrl: string
|
||||
cdnBaseUrl: string
|
||||
token: string
|
||||
onMessage: OnMessageCallback
|
||||
onPermissionResponse?: OnPermissionResponseCallback
|
||||
},
|
||||
): Promise<void> {
|
||||
if (msg.message_type !== MessageType.USER) return
|
||||
const fromUserId = msg.from_user_id
|
||||
if (!fromUserId) return
|
||||
|
||||
if (msg.context_token) {
|
||||
contextTokens.set(fromUserId, msg.context_token)
|
||||
}
|
||||
|
||||
if (!isAllowed(fromUserId)) {
|
||||
const code = addPendingPairing(fromUserId)
|
||||
try {
|
||||
await sendText({
|
||||
to: fromUserId,
|
||||
text: `Your pairing code is: ${code}\n\nAsk the operator to confirm:\nccb weixin access pair ${code}`,
|
||||
baseUrl: ctx.baseUrl,
|
||||
token: ctx.token,
|
||||
contextToken: msg.context_token || '',
|
||||
})
|
||||
} catch (error) {
|
||||
process.stderr.write(`[weixin] Failed to send pairing code: ${error}\n`)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
setActivePermissionChat(fromUserId, msg.context_token)
|
||||
|
||||
let textContent = ''
|
||||
let mediaPath: string | undefined
|
||||
let mediaType: string | undefined
|
||||
|
||||
if (msg.item_list) {
|
||||
for (const item of msg.item_list) {
|
||||
if (item.type === MessageItemType.TEXT && item.text_item?.text) {
|
||||
textContent += `${textContent ? '\n' : ''}${item.text_item.text}`
|
||||
} else if (
|
||||
item.type === MessageItemType.IMAGE ||
|
||||
item.type === MessageItemType.VOICE ||
|
||||
item.type === MessageItemType.FILE ||
|
||||
item.type === MessageItemType.VIDEO
|
||||
) {
|
||||
const downloaded = await downloadMedia(item, ctx.cdnBaseUrl)
|
||||
if (downloaded) {
|
||||
mediaPath = downloaded.path
|
||||
mediaType = downloaded.type
|
||||
}
|
||||
if (item.type === MessageItemType.VOICE && item.voice_item?.text) {
|
||||
textContent += `${textContent ? '\n' : ''}[Voice transcription]: ${item.voice_item.text}`
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!textContent && !mediaPath) return
|
||||
|
||||
if (textContent && ctx.onPermissionResponse) {
|
||||
const permissionReply = extractPermissionReply(textContent)
|
||||
if (permissionReply) {
|
||||
const pending = consumePendingPermission(
|
||||
permissionReply.requestId,
|
||||
fromUserId,
|
||||
)
|
||||
if (pending) {
|
||||
await ctx.onPermissionResponse({
|
||||
requestId: pending.request_id,
|
||||
behavior: permissionReply.behavior,
|
||||
fromUserId,
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await ctx.onMessage({
|
||||
fromUserId,
|
||||
messageId: String(msg.message_id || ''),
|
||||
text: textContent || '(media attachment)',
|
||||
attachmentPath: mediaPath,
|
||||
attachmentType: mediaType,
|
||||
})
|
||||
}
|
||||
101
src/services/weixin/pairing.ts
Normal file
101
src/services/weixin/pairing.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { getStateDir } from './accounts.js'
|
||||
|
||||
export interface AccessConfig {
|
||||
policy: 'pairing' | 'allowlist' | 'disabled'
|
||||
allowFrom: string[]
|
||||
}
|
||||
|
||||
interface PendingEntry {
|
||||
userId: string
|
||||
expiresAt: number
|
||||
}
|
||||
|
||||
function configPath(): string {
|
||||
return join(getStateDir(), 'access.json')
|
||||
}
|
||||
|
||||
function pendingPath(): string {
|
||||
return join(getStateDir(), 'pending-pairings.json')
|
||||
}
|
||||
|
||||
function loadPending(): Record<string, PendingEntry> {
|
||||
const path = pendingPath()
|
||||
if (!existsSync(path)) return {}
|
||||
try {
|
||||
return JSON.parse(readFileSync(path, 'utf-8')) as Record<string, PendingEntry>
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
function savePending(data: Record<string, PendingEntry>): void {
|
||||
writeFileSync(pendingPath(), JSON.stringify(data, null, 2), 'utf-8')
|
||||
}
|
||||
|
||||
export function loadAccessConfig(): AccessConfig {
|
||||
const path = configPath()
|
||||
if (!existsSync(path)) {
|
||||
return { policy: 'pairing', allowFrom: [] }
|
||||
}
|
||||
try {
|
||||
return JSON.parse(readFileSync(path, 'utf-8')) as AccessConfig
|
||||
} catch {
|
||||
return { policy: 'pairing', allowFrom: [] }
|
||||
}
|
||||
}
|
||||
|
||||
export function saveAccessConfig(config: AccessConfig): void {
|
||||
writeFileSync(configPath(), JSON.stringify(config, null, 2), 'utf-8')
|
||||
}
|
||||
|
||||
export function isAllowed(userId: string): boolean {
|
||||
const config = loadAccessConfig()
|
||||
if (config.policy === 'disabled') return true
|
||||
return config.allowFrom.includes(userId)
|
||||
}
|
||||
|
||||
export function addPendingPairing(userId: string): string {
|
||||
const pending = loadPending()
|
||||
const now = Date.now()
|
||||
|
||||
for (const code of Object.keys(pending)) {
|
||||
if (pending[code]!.expiresAt < now) {
|
||||
delete pending[code]
|
||||
}
|
||||
}
|
||||
|
||||
for (const [code, entry] of Object.entries(pending)) {
|
||||
if (entry.userId === userId) {
|
||||
savePending(pending)
|
||||
return code
|
||||
}
|
||||
}
|
||||
|
||||
const code = String(Math.floor(100000 + Math.random() * 900000))
|
||||
pending[code] = { userId, expiresAt: now + 10 * 60 * 1000 }
|
||||
savePending(pending)
|
||||
return code
|
||||
}
|
||||
|
||||
export function confirmPairing(code: string): string | null {
|
||||
const pending = loadPending()
|
||||
const entry = pending[code]
|
||||
if (!entry || entry.expiresAt < Date.now()) {
|
||||
delete pending[code]
|
||||
savePending(pending)
|
||||
return null
|
||||
}
|
||||
|
||||
delete pending[code]
|
||||
savePending(pending)
|
||||
|
||||
const config = loadAccessConfig()
|
||||
if (!config.allowFrom.includes(entry.userId)) {
|
||||
config.allowFrom.push(entry.userId)
|
||||
saveAccessConfig(config)
|
||||
}
|
||||
|
||||
return entry.userId
|
||||
}
|
||||
73
src/services/weixin/permissions.ts
Normal file
73
src/services/weixin/permissions.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import type { ChannelPermissionRequestParams } from '../mcp/channelNotification.js'
|
||||
|
||||
export type PendingPermissionRequest = ChannelPermissionRequestParams & {
|
||||
chatId: string
|
||||
contextToken?: string
|
||||
createdAt: number
|
||||
expiresAt: number
|
||||
}
|
||||
|
||||
export type ActivePermissionChat = {
|
||||
chatId: string
|
||||
contextToken?: string
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
const PENDING_PERMISSION_TTL_MS = 15 * 60 * 1000
|
||||
|
||||
const pendingPermissions = new Map<string, PendingPermissionRequest>()
|
||||
let activePermissionChat: ActivePermissionChat | null = null
|
||||
|
||||
function pruneExpiredPendingPermissions(now = Date.now()): void {
|
||||
for (const [requestId, entry] of pendingPermissions.entries()) {
|
||||
if (entry.expiresAt <= now) {
|
||||
pendingPermissions.delete(requestId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function setActivePermissionChat(
|
||||
chatId: string,
|
||||
contextToken?: string,
|
||||
): void {
|
||||
activePermissionChat = { chatId, contextToken, updatedAt: Date.now() }
|
||||
}
|
||||
|
||||
export function getActivePermissionChat(): ActivePermissionChat | null {
|
||||
return activePermissionChat
|
||||
}
|
||||
|
||||
export function savePendingPermission(
|
||||
request: ChannelPermissionRequestParams,
|
||||
chatId: string,
|
||||
contextToken?: string,
|
||||
): PendingPermissionRequest {
|
||||
pruneExpiredPendingPermissions()
|
||||
const entry: PendingPermissionRequest = {
|
||||
...request,
|
||||
chatId,
|
||||
contextToken,
|
||||
createdAt: Date.now(),
|
||||
expiresAt: Date.now() + PENDING_PERMISSION_TTL_MS,
|
||||
}
|
||||
pendingPermissions.set(request.request_id.toLowerCase(), entry)
|
||||
return entry
|
||||
}
|
||||
|
||||
export function consumePendingPermission(
|
||||
requestId: string,
|
||||
fromUserId: string,
|
||||
): PendingPermissionRequest | null {
|
||||
pruneExpiredPendingPermissions()
|
||||
const key = requestId.toLowerCase()
|
||||
const entry = pendingPermissions.get(key)
|
||||
if (!entry) return null
|
||||
if (entry.chatId !== fromUserId) return null
|
||||
pendingPermissions.delete(key)
|
||||
return entry
|
||||
}
|
||||
|
||||
export function clearPermissionStateForTests(): void {
|
||||
pendingPermissions.clear()
|
||||
activePermissionChat = null
|
||||
}
|
||||
144
src/services/weixin/send.ts
Normal file
144
src/services/weixin/send.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import type { CDNMedia, MessageItem } from './types.js'
|
||||
import { sendMessage } from './api.js'
|
||||
import { guessMediaType, uploadFile } from './media.js'
|
||||
import { MessageItemType, MessageState, MessageType } from './types.js'
|
||||
|
||||
export function markdownToPlainText(text: string): string {
|
||||
return text
|
||||
.replace(/```[\s\S]*?\n([\s\S]*?)```/g, '$1')
|
||||
.replace(/`([^`]+)`/g, '$1')
|
||||
.replace(/\*\*\*(.+?)\*\*\*/g, '$1')
|
||||
.replace(/\*\*(.+?)\*\*/g, '$1')
|
||||
.replace(/\*(.+?)\*/g, '$1')
|
||||
.replace(/___(.+?)___/g, '$1')
|
||||
.replace(/__(.+?)__/g, '$1')
|
||||
.replace(/_(.+?)_/g, '$1')
|
||||
.replace(/~~(.+?)~~/g, '$1')
|
||||
.replace(/^#{1,6}\s+/gm, '')
|
||||
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1 ($2)')
|
||||
.replace(/!\[([^\]]*)\]\([^)]+\)/g, '[$1]')
|
||||
.replace(/^>\s+/gm, '')
|
||||
.replace(/^[-*_]{3,}$/gm, '---')
|
||||
.replace(/^[\s]*[-*+]\s+/gm, '- ')
|
||||
.replace(/^[\s]*(\d+)\.\s+/gm, '$1. ')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim()
|
||||
}
|
||||
|
||||
export async function sendText(params: {
|
||||
to: string
|
||||
text: string
|
||||
baseUrl: string
|
||||
token: string
|
||||
contextToken: string
|
||||
}): Promise<{ messageId: string }> {
|
||||
const clientId = randomUUID()
|
||||
await sendMessage(params.baseUrl, params.token, {
|
||||
to_user_id: params.to,
|
||||
from_user_id: '',
|
||||
client_id: clientId,
|
||||
message_type: MessageType.BOT,
|
||||
message_state: MessageState.FINISH,
|
||||
context_token: params.contextToken,
|
||||
item_list: [
|
||||
{
|
||||
type: MessageItemType.TEXT,
|
||||
text_item: { text: markdownToPlainText(params.text) },
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
return { messageId: clientId }
|
||||
}
|
||||
|
||||
async function sendItems(params: {
|
||||
items: MessageItem[]
|
||||
to: string
|
||||
baseUrl: string
|
||||
token: string
|
||||
contextToken: string
|
||||
}): Promise<string> {
|
||||
let lastClientId = ''
|
||||
for (const item of params.items) {
|
||||
lastClientId = randomUUID()
|
||||
await sendMessage(params.baseUrl, params.token, {
|
||||
to_user_id: params.to,
|
||||
from_user_id: '',
|
||||
client_id: lastClientId,
|
||||
message_type: MessageType.BOT,
|
||||
message_state: MessageState.FINISH,
|
||||
context_token: params.contextToken,
|
||||
item_list: [item],
|
||||
})
|
||||
}
|
||||
return lastClientId
|
||||
}
|
||||
|
||||
export async function sendMediaFile(params: {
|
||||
filePath: string
|
||||
to: string
|
||||
text: string
|
||||
baseUrl: string
|
||||
token: string
|
||||
contextToken: string
|
||||
cdnBaseUrl: string
|
||||
}): Promise<{ messageId: string }> {
|
||||
const mediaType = guessMediaType(params.filePath)
|
||||
const uploaded = await uploadFile({
|
||||
filePath: params.filePath,
|
||||
toUserId: params.to,
|
||||
mediaType,
|
||||
apiBaseUrl: params.baseUrl,
|
||||
token: params.token,
|
||||
cdnBaseUrl: params.cdnBaseUrl,
|
||||
})
|
||||
|
||||
const cdnMedia: CDNMedia = {
|
||||
encrypt_query_param: uploaded.encryptQueryParam,
|
||||
aes_key: uploaded.aesKey,
|
||||
encrypt_type: 1,
|
||||
}
|
||||
|
||||
const items: MessageItem[] = []
|
||||
if (params.text) {
|
||||
items.push({
|
||||
type: MessageItemType.TEXT,
|
||||
text_item: { text: markdownToPlainText(params.text) },
|
||||
})
|
||||
}
|
||||
|
||||
switch (mediaType) {
|
||||
case 1:
|
||||
items.push({
|
||||
type: MessageItemType.IMAGE,
|
||||
image_item: { media: cdnMedia, mid_size: uploaded.fileSize },
|
||||
})
|
||||
break
|
||||
case 2:
|
||||
items.push({
|
||||
type: MessageItemType.VIDEO,
|
||||
video_item: { media: cdnMedia, video_size: uploaded.fileSize },
|
||||
})
|
||||
break
|
||||
default:
|
||||
items.push({
|
||||
type: MessageItemType.FILE,
|
||||
file_item: {
|
||||
media: cdnMedia,
|
||||
file_name: uploaded.fileName,
|
||||
len: String(uploaded.rawSize),
|
||||
},
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
const messageId = await sendItems({
|
||||
items,
|
||||
to: params.to,
|
||||
baseUrl: params.baseUrl,
|
||||
token: params.token,
|
||||
contextToken: params.contextToken,
|
||||
})
|
||||
return { messageId }
|
||||
}
|
||||
341
src/services/weixin/server.ts
Normal file
341
src/services/weixin/server.ts
Normal file
@@ -0,0 +1,341 @@
|
||||
import { existsSync } from 'node:fs'
|
||||
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
|
||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
|
||||
import {
|
||||
CallToolRequestSchema,
|
||||
ListToolsRequestSchema,
|
||||
} from '@modelcontextprotocol/sdk/types.js'
|
||||
import {
|
||||
ChannelPermissionRequestNotificationSchema,
|
||||
type ChannelPermissionRequestParams,
|
||||
} from '../mcp/channelNotification.js'
|
||||
import { initializeAnalyticsSink } from '../analytics/sink.js'
|
||||
import { shutdownDatadog } from '../analytics/datadog.js'
|
||||
import { shutdown1PEventLogging } from '../analytics/firstPartyEventLogger.js'
|
||||
import { enableConfigs } from '../../utils/config.js'
|
||||
import { logForDebugging } from '../../utils/debug.js'
|
||||
import { CDN_BASE_URL, DEFAULT_BASE_URL, loadAccount } from './accounts.js'
|
||||
import { getConfig, sendTyping } from './api.js'
|
||||
import { getContextToken, startPollLoop, type ParsedMessage } from './monitor.js'
|
||||
import { getActivePermissionChat, savePendingPermission } from './permissions.js'
|
||||
import { sendMediaFile, sendText } from './send.js'
|
||||
import { TypingStatus } from './types.js'
|
||||
|
||||
function formatPermissionRequestMessage(
|
||||
request: ChannelPermissionRequestParams,
|
||||
): string {
|
||||
return [
|
||||
'Claude Code needs your approval.',
|
||||
'',
|
||||
`Tool: ${request.tool_name}`,
|
||||
`Reason: ${request.description}`,
|
||||
`Input: ${request.input_preview}`,
|
||||
'',
|
||||
`Reply with: yes ${request.request_id}`,
|
||||
`Or deny with: no ${request.request_id}`,
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
export function createWeixinMcpServer(): Server {
|
||||
const server = new Server(
|
||||
{ name: 'weixin', version: MACRO.VERSION },
|
||||
{
|
||||
capabilities: {
|
||||
experimental: {
|
||||
'claude/channel': {},
|
||||
'claude/channel/permission': {},
|
||||
},
|
||||
tools: {},
|
||||
},
|
||||
instructions:
|
||||
'Messages from WeChat arrive as <channel source="plugin:weixin:weixin" chat_id="..." sender_id="...">. Reply using the reply tool with the chat_id from the channel tag. Use absolute paths for file attachments.',
|
||||
},
|
||||
)
|
||||
|
||||
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
||||
tools: [
|
||||
{
|
||||
name: 'reply',
|
||||
description:
|
||||
'Reply to a WeChat message. Pass the chat_id from the channel tag.',
|
||||
inputSchema: {
|
||||
type: 'object' as const,
|
||||
properties: {
|
||||
chat_id: {
|
||||
type: 'string',
|
||||
description: 'The chat_id from the channel notification',
|
||||
},
|
||||
text: { type: 'string', description: 'The reply text' },
|
||||
files: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
description: 'Optional absolute file paths to attach',
|
||||
},
|
||||
},
|
||||
required: ['chat_id', 'text'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'send_typing',
|
||||
description: 'Send a typing indicator to a WeChat user.',
|
||||
inputSchema: {
|
||||
type: 'object' as const,
|
||||
properties: {
|
||||
chat_id: { type: 'string', description: 'The chat_id (user ID)' },
|
||||
},
|
||||
required: ['chat_id'],
|
||||
},
|
||||
},
|
||||
],
|
||||
}))
|
||||
|
||||
server.setRequestHandler(CallToolRequestSchema, async request => {
|
||||
const { name, arguments: args } = request.params
|
||||
const account = loadAccount()
|
||||
if (!account) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'WeChat not connected. Run `ccb weixin login` first.',
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
}
|
||||
}
|
||||
|
||||
const baseUrl = account.baseUrl || DEFAULT_BASE_URL
|
||||
const cdnBaseUrl = CDN_BASE_URL
|
||||
|
||||
switch (name) {
|
||||
case 'reply': {
|
||||
const chatId = typeof args?.chat_id === 'string' ? args.chat_id : ''
|
||||
const text = typeof args?.text === 'string' ? args.text : ''
|
||||
const files = Array.isArray(args?.files)
|
||||
? args.files.filter((value): value is string => typeof value === 'string')
|
||||
: undefined
|
||||
|
||||
if (!chatId || !text) {
|
||||
return {
|
||||
content: [
|
||||
{ type: 'text', text: 'Missing chat_id or text parameter.' },
|
||||
],
|
||||
isError: true,
|
||||
}
|
||||
}
|
||||
|
||||
const contextToken = getContextToken(chatId) || ''
|
||||
|
||||
try {
|
||||
if (files && files.length > 0) {
|
||||
for (const [index, filePath] of files.entries()) {
|
||||
if (!existsSync(filePath)) {
|
||||
return {
|
||||
content: [
|
||||
{ type: 'text', text: `File not found: ${filePath}` },
|
||||
],
|
||||
isError: true,
|
||||
}
|
||||
}
|
||||
await sendMediaFile({
|
||||
filePath,
|
||||
to: chatId,
|
||||
text: index === 0 ? text : '',
|
||||
baseUrl,
|
||||
token: account.token,
|
||||
contextToken,
|
||||
cdnBaseUrl,
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: 'Message sent with attachments.' }],
|
||||
}
|
||||
}
|
||||
|
||||
await sendText({
|
||||
to: chatId,
|
||||
text,
|
||||
baseUrl,
|
||||
token: account.token,
|
||||
contextToken,
|
||||
})
|
||||
return { content: [{ type: 'text', text: 'Message sent.' }] }
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [{ type: 'text', text: `Failed to send: ${error}` }],
|
||||
isError: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
case 'send_typing': {
|
||||
const chatId = typeof args?.chat_id === 'string' ? args.chat_id : ''
|
||||
if (!chatId) {
|
||||
return {
|
||||
content: [{ type: 'text', text: 'Missing chat_id parameter.' }],
|
||||
isError: true,
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const contextToken = getContextToken(chatId)
|
||||
const config = await getConfig(
|
||||
baseUrl,
|
||||
account.token,
|
||||
chatId,
|
||||
contextToken,
|
||||
)
|
||||
if (config.typing_ticket) {
|
||||
await sendTyping(baseUrl, account.token, {
|
||||
ilink_user_id: chatId,
|
||||
typing_ticket: config.typing_ticket,
|
||||
status: TypingStatus.TYPING,
|
||||
})
|
||||
}
|
||||
return {
|
||||
content: [{ type: 'text', text: 'Typing indicator sent.' }],
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [{ type: 'text', text: `Failed to send typing: ${error}` }],
|
||||
isError: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
default:
|
||||
return {
|
||||
content: [{ type: 'text', text: `Unknown tool: ${name}` }],
|
||||
isError: true,
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return server
|
||||
}
|
||||
|
||||
export async function runWeixinMcpServer(): Promise<void> {
|
||||
enableConfigs()
|
||||
initializeAnalyticsSink()
|
||||
|
||||
const account = loadAccount()
|
||||
if (!account) {
|
||||
process.stderr.write(
|
||||
'[weixin] No account configured. Run `ccb weixin login` to connect your WeChat account.\n',
|
||||
)
|
||||
await Promise.all([shutdown1PEventLogging(), shutdownDatadog()])
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const server = createWeixinMcpServer()
|
||||
const transport = new StdioServerTransport()
|
||||
|
||||
server.setNotificationHandler(
|
||||
ChannelPermissionRequestNotificationSchema(),
|
||||
async notification => {
|
||||
const request = notification.params
|
||||
const targetChatId = request.channel_context?.chat_id
|
||||
const targetChat = targetChatId
|
||||
? {
|
||||
chatId: targetChatId,
|
||||
contextToken: getContextToken(targetChatId),
|
||||
}
|
||||
: getActivePermissionChat()
|
||||
|
||||
if (!targetChat) {
|
||||
logForDebugging(
|
||||
`[Weixin MCP] No active chat available for permission request ${request.request_id}`,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
savePendingPermission(
|
||||
request,
|
||||
targetChat.chatId,
|
||||
targetChat.contextToken,
|
||||
)
|
||||
await sendText({
|
||||
to: targetChat.chatId,
|
||||
text: formatPermissionRequestMessage(request),
|
||||
baseUrl,
|
||||
token: account.token,
|
||||
contextToken: targetChat.contextToken || '',
|
||||
})
|
||||
} catch (error) {
|
||||
process.stderr.write(
|
||||
`[weixin] Failed to relay permission request ${request.request_id}: ${error}\n`,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
await server.connect(transport)
|
||||
|
||||
const baseUrl = account.baseUrl || DEFAULT_BASE_URL
|
||||
const controller = new AbortController()
|
||||
|
||||
let exiting = false
|
||||
const shutdownAndExit = async (): Promise<void> => {
|
||||
if (exiting) return
|
||||
exiting = true
|
||||
if (!controller.signal.aborted) {
|
||||
controller.abort()
|
||||
}
|
||||
await Promise.all([shutdown1PEventLogging(), shutdownDatadog()])
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
process.stdin.on('end', () => void shutdownAndExit())
|
||||
process.stdin.on('error', () => void shutdownAndExit())
|
||||
process.on('SIGINT', () => void shutdownAndExit())
|
||||
process.on('SIGTERM', () => void shutdownAndExit())
|
||||
process.on('SIGHUP', () => void shutdownAndExit())
|
||||
|
||||
const ppid = process.ppid
|
||||
const parentCheck = setInterval(() => {
|
||||
try {
|
||||
process.kill(ppid, 0)
|
||||
} catch {
|
||||
process.stderr.write('[weixin] Parent process exited, shutting down...\n')
|
||||
clearInterval(parentCheck)
|
||||
void shutdownAndExit()
|
||||
}
|
||||
}, 5000)
|
||||
|
||||
logForDebugging('[Weixin MCP] Starting poll loop')
|
||||
await startPollLoop({
|
||||
baseUrl,
|
||||
cdnBaseUrl: CDN_BASE_URL,
|
||||
token: account.token,
|
||||
onMessage: async (msg: ParsedMessage) => {
|
||||
await server.notification({
|
||||
method: 'notifications/claude/channel',
|
||||
params: {
|
||||
content: msg.text,
|
||||
meta: {
|
||||
chat_id: msg.fromUserId,
|
||||
sender_id: msg.fromUserId,
|
||||
message_id: msg.messageId,
|
||||
...(msg.attachmentPath && { attachment_path: msg.attachmentPath }),
|
||||
...(msg.attachmentType && { attachment_type: msg.attachmentType }),
|
||||
},
|
||||
},
|
||||
})
|
||||
},
|
||||
onPermissionResponse: async response => {
|
||||
await server.notification({
|
||||
method: 'notifications/claude/channel/permission',
|
||||
params: {
|
||||
request_id: response.requestId,
|
||||
behavior: response.behavior,
|
||||
},
|
||||
})
|
||||
},
|
||||
abortSignal: controller.signal,
|
||||
})
|
||||
|
||||
clearInterval(parentCheck)
|
||||
await shutdownAndExit()
|
||||
}
|
||||
178
src/services/weixin/types.ts
Normal file
178
src/services/weixin/types.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
export const MessageType = {
|
||||
NONE: 0,
|
||||
USER: 1,
|
||||
BOT: 2,
|
||||
} as const
|
||||
|
||||
export const MessageItemType = {
|
||||
NONE: 0,
|
||||
TEXT: 1,
|
||||
IMAGE: 2,
|
||||
VOICE: 3,
|
||||
FILE: 4,
|
||||
VIDEO: 5,
|
||||
} as const
|
||||
|
||||
export const MessageState = {
|
||||
NEW: 0,
|
||||
GENERATING: 1,
|
||||
FINISH: 2,
|
||||
} as const
|
||||
|
||||
export const UploadMediaType = {
|
||||
IMAGE: 1,
|
||||
VIDEO: 2,
|
||||
FILE: 3,
|
||||
VOICE: 4,
|
||||
} as const
|
||||
|
||||
export const TypingStatus = {
|
||||
TYPING: 1,
|
||||
CANCEL: 2,
|
||||
} as const
|
||||
|
||||
export interface BaseInfo {
|
||||
channel_version?: string
|
||||
}
|
||||
|
||||
export interface CDNMedia {
|
||||
encrypt_query_param?: string
|
||||
aes_key?: string
|
||||
encrypt_type?: number
|
||||
}
|
||||
|
||||
export interface TextItem {
|
||||
text?: string
|
||||
}
|
||||
|
||||
export interface ImageItem {
|
||||
media?: CDNMedia
|
||||
thumb_media?: CDNMedia
|
||||
aeskey?: string
|
||||
url?: string
|
||||
mid_size?: number
|
||||
thumb_size?: number
|
||||
thumb_height?: number
|
||||
thumb_width?: number
|
||||
hd_size?: number
|
||||
}
|
||||
|
||||
export interface VoiceItem {
|
||||
media?: CDNMedia
|
||||
encode_type?: number
|
||||
bits_per_sample?: number
|
||||
sample_rate?: number
|
||||
playtime?: number
|
||||
text?: string
|
||||
}
|
||||
|
||||
export interface FileItem {
|
||||
media?: CDNMedia
|
||||
file_name?: string
|
||||
md5?: string
|
||||
len?: string
|
||||
}
|
||||
|
||||
export interface VideoItem {
|
||||
media?: CDNMedia
|
||||
video_size?: number
|
||||
play_length?: number
|
||||
video_md5?: string
|
||||
thumb_media?: CDNMedia
|
||||
thumb_size?: number
|
||||
thumb_height?: number
|
||||
thumb_width?: number
|
||||
}
|
||||
|
||||
export interface RefMessage {
|
||||
message_item?: MessageItem
|
||||
title?: string
|
||||
}
|
||||
|
||||
export interface MessageItem {
|
||||
type?: number
|
||||
create_time_ms?: number
|
||||
update_time_ms?: number
|
||||
is_completed?: boolean
|
||||
msg_id?: string
|
||||
ref_msg?: RefMessage
|
||||
text_item?: TextItem
|
||||
image_item?: ImageItem
|
||||
voice_item?: VoiceItem
|
||||
file_item?: FileItem
|
||||
video_item?: VideoItem
|
||||
}
|
||||
|
||||
export interface WeixinMessage {
|
||||
seq?: number
|
||||
message_id?: number
|
||||
from_user_id?: string
|
||||
to_user_id?: string
|
||||
client_id?: string
|
||||
create_time_ms?: number
|
||||
update_time_ms?: number
|
||||
delete_time_ms?: number
|
||||
session_id?: string
|
||||
group_id?: string
|
||||
message_type?: number
|
||||
message_state?: number
|
||||
item_list?: MessageItem[]
|
||||
context_token?: string
|
||||
}
|
||||
|
||||
export interface GetUpdatesReq {
|
||||
get_updates_buf?: string
|
||||
base_info?: BaseInfo
|
||||
}
|
||||
|
||||
export interface GetUpdatesResp {
|
||||
ret?: number
|
||||
errcode?: number
|
||||
errmsg?: string
|
||||
msgs?: WeixinMessage[]
|
||||
get_updates_buf?: string
|
||||
longpolling_timeout_ms?: number
|
||||
}
|
||||
|
||||
export interface SendMessageReq {
|
||||
msg?: WeixinMessage
|
||||
base_info?: BaseInfo
|
||||
}
|
||||
|
||||
export interface GetUploadUrlReq {
|
||||
filekey?: string
|
||||
media_type?: number
|
||||
to_user_id?: string
|
||||
rawsize?: number
|
||||
rawfilemd5?: string
|
||||
filesize?: number
|
||||
thumb_rawsize?: number
|
||||
thumb_rawfilemd5?: string
|
||||
thumb_filesize?: number
|
||||
no_need_thumb?: boolean
|
||||
aeskey?: string
|
||||
base_info?: BaseInfo
|
||||
}
|
||||
|
||||
export interface GetUploadUrlResp {
|
||||
upload_param?: string
|
||||
thumb_upload_param?: string
|
||||
}
|
||||
|
||||
export interface GetConfigResp {
|
||||
ret?: number
|
||||
errmsg?: string
|
||||
typing_ticket?: string
|
||||
}
|
||||
|
||||
export interface SendTypingReq {
|
||||
ilink_user_id?: string
|
||||
typing_ticket?: string
|
||||
status?: number
|
||||
base_info?: BaseInfo
|
||||
}
|
||||
|
||||
export interface SendTypingResp {
|
||||
ret?: number
|
||||
errmsg?: string
|
||||
}
|
||||
11
src/types/qrcode-terminal.d.ts
vendored
Normal file
11
src/types/qrcode-terminal.d.ts
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
declare module 'qrcode-terminal' {
|
||||
const qrcode: {
|
||||
generate(
|
||||
text: string,
|
||||
options?: { small?: boolean },
|
||||
callback?: (output: string) => void,
|
||||
): void
|
||||
}
|
||||
|
||||
export default qrcode
|
||||
}
|
||||
Reference in New Issue
Block a user