mirror of
https://github.com/NanmiCoder/claude-code-haha.git
synced 2026-10-10 11:53:10 +08:00
814c02a786
Connecting Feishu meant creating a bot by hand on the open platform and pasting an App ID and App Secret back. Feishu also exposes an RFC 8628 device-authorization flow, so the desktop can now render a QR code, and confirming it in the app creates the bot and stores its credentials directly. `adapters/feishu/registration.ts` implements that protocol rather than importing `registerApp` from `@larksuiteoapi/node-sdk@1.73`: the repository pins 1.60 for the chat client, and the SDK runs the whole poll inside one un-cancellable promise where the desktop needs the stateless begin/poll pair the DingTalk registration already uses. The scan is create-only, so it can never rewrite the configuration of a bot the user already runs, and it pre-fills exactly the scopes, events and callbacks this adapter calls. International tenants finish on Lark's domain, which is now persisted and honoured by the client. WeCom, QQ and Slack join the same session model. WeCom and QQ bind by scanning; Slack has no scan flow, so it uses an app manifest that pre-fills the scopes and Socket Mode. All three run over long connections, so no public callback URL is needed, and all three accept private chats only — pairing authorizes one person, and answering in a group would extend that authorization to everyone else in the room. They are built on a new `adapters/common/chat-runtime.ts` instead of a fourth copy of the loop the five existing adapters each carry. A platform supplies a `ChatPort` — how to say something, how to open a streaming reply, optionally how to send an image — and the runtime owns pairing, command routing, session restore, permission bookkeeping and the translation of the server's stream. The existing five are deliberately left on their own copies; migrating them is a separate change with its own regression surface. Attachments are downloaded through a deferred loader that runs after the pairing gate and inside the per-chat queue. Resolving them eagerly would let an unpaired stranger make the adapter fetch bytes and write them under ~/.claude/im-downloads — on Slack with the bot token attached — and would let a slow attachment overtake a text message sent after it. The sidecar launcher's per-adapter branches become one table. It is declared above the mode dispatch on purpose: `runAdapters` is hoisted and runs at module top level, so a table declared below it is still in its temporal dead zone when the adapters mode reads it — which type checks, lints and unit tests all miss, and only the compiled binary reveals. Verified with the checks `check:impact` selects: adapters, server, desktop, electron, policy, chat-contract, agent-flow, docs, native (sidecar compile, packaging and an adapters-mode smoke against the real binary) and coverage. The scan flows themselves are not verified against live platforms — that needs real WeCom, QQ and Slack accounts and would create real bots. Claude-Session: https://claude.ai/code/session_01CCGoP316AK7wdQG3Ms6Uwq
277 lines
8.3 KiB
TypeScript
277 lines
8.3 KiB
TypeScript
import { describe, it, expect } from 'bun:test'
|
|
import {
|
|
convertMarkdownTablesToBullets,
|
|
formatImHelp,
|
|
formatImStatus,
|
|
splitMessage,
|
|
splitMessageByBytes,
|
|
utf8Length,
|
|
formatToolUse,
|
|
formatPermissionRequest,
|
|
truncateInput,
|
|
escapeMarkdownV2,
|
|
} from '../format.js'
|
|
|
|
describe('splitMessage', () => {
|
|
it('returns single chunk for short text', () => {
|
|
expect(splitMessage('hello', 100)).toEqual(['hello'])
|
|
})
|
|
|
|
it('splits at paragraph boundary', () => {
|
|
const text = 'First paragraph.\n\nSecond paragraph.'
|
|
const chunks = splitMessage(text, 20)
|
|
expect(chunks.length).toBeGreaterThan(1)
|
|
expect(chunks.join(' ').replace(/\s+/g, ' ')).toContain('First paragraph')
|
|
expect(chunks.join(' ').replace(/\s+/g, ' ')).toContain('Second paragraph')
|
|
})
|
|
|
|
it('splits at newline if no paragraph break', () => {
|
|
const text = 'Line one\nLine two\nLine three\nLine four'
|
|
const chunks = splitMessage(text, 20)
|
|
expect(chunks.length).toBeGreaterThan(1)
|
|
})
|
|
|
|
it('hard-splits at limit if no natural break', () => {
|
|
const text = 'a'.repeat(50)
|
|
const chunks = splitMessage(text, 20)
|
|
expect(chunks.length).toBe(3) // 20 + 20 + 10
|
|
expect(chunks.every((c) => c.length <= 20)).toBe(true)
|
|
})
|
|
|
|
it('preserves all content after splitting', () => {
|
|
const text = 'Hello world. This is a test. Foo bar baz.'
|
|
const chunks = splitMessage(text, 15)
|
|
const joined = chunks.join(' ')
|
|
// All words should be present
|
|
expect(joined).toContain('Hello')
|
|
expect(joined).toContain('test')
|
|
expect(joined).toContain('baz')
|
|
})
|
|
})
|
|
|
|
describe('convertMarkdownTablesToBullets', () => {
|
|
it('converts pipe tables into row-labeled bullets', () => {
|
|
const markdown = [
|
|
'Before',
|
|
'',
|
|
'| Feature | Status | Notes |',
|
|
'| --- | --- | --- |',
|
|
'| Auth | Done | OAuth2 |',
|
|
'| API | WIP | REST only |',
|
|
'',
|
|
'After',
|
|
].join('\n')
|
|
|
|
expect(convertMarkdownTablesToBullets(markdown)).toBe([
|
|
'Before',
|
|
'',
|
|
'Auth',
|
|
'• Status: Done',
|
|
'• Notes: OAuth2',
|
|
'',
|
|
'API',
|
|
'• Status: WIP',
|
|
'• Notes: REST only',
|
|
'',
|
|
'After',
|
|
].join('\n'))
|
|
})
|
|
|
|
it('skips empty table cells', () => {
|
|
const markdown = [
|
|
'| Item | Value | Notes |',
|
|
'| --- | --- | --- |',
|
|
'| One | 1 | |',
|
|
].join('\n')
|
|
|
|
expect(convertMarkdownTablesToBullets(markdown)).toBe([
|
|
'One',
|
|
'• Value: 1',
|
|
].join('\n'))
|
|
})
|
|
|
|
it('leaves non-table pipe text unchanged', () => {
|
|
const markdown = 'Use foo | bar as plain text.'
|
|
expect(convertMarkdownTablesToBullets(markdown)).toBe(markdown)
|
|
})
|
|
|
|
it('does not rewrite pipe tables inside fenced code blocks', () => {
|
|
const markdown = [
|
|
'```',
|
|
'| Feature | Status |',
|
|
'| --- | --- |',
|
|
'| Auth | Done |',
|
|
'```',
|
|
].join('\n')
|
|
|
|
expect(convertMarkdownTablesToBullets(markdown)).toBe(markdown)
|
|
})
|
|
})
|
|
|
|
describe('formatToolUse', () => {
|
|
it('includes tool name and input preview', () => {
|
|
const result = formatToolUse('Bash', { command: 'npm test' })
|
|
expect(result).toContain('🔧 Bash')
|
|
expect(result).toContain('npm test')
|
|
})
|
|
|
|
it('summarizes file tools with a shortened path', () => {
|
|
const result = formatToolUse('Read', { file_path: '/Users/test/project/src/index.ts' })
|
|
|
|
expect(result).toBe('🔧 Read …/project/src/index.ts')
|
|
})
|
|
|
|
it('shortens Windows paths in file tool summaries', () => {
|
|
const result = formatToolUse('Read', { file_path: 'C:\\Users\\test\\project\\src\\index.ts' })
|
|
expect(result).toBe('🔧 Read …/project/src/index.ts')
|
|
})
|
|
})
|
|
|
|
|
|
describe('formatPermissionRequest', () => {
|
|
it('includes tool name, input preview, and request ID', () => {
|
|
const result = formatPermissionRequest('Bash', { command: 'rm -rf /' }, 'abcde')
|
|
expect(result).toContain('🔐')
|
|
expect(result).toContain('Bash')
|
|
expect(result).toContain('abcde')
|
|
expect(result).toContain('rm -rf')
|
|
})
|
|
})
|
|
|
|
describe('truncateInput', () => {
|
|
it('returns short input as-is', () => {
|
|
expect(truncateInput('hello', 100)).toBe('hello')
|
|
})
|
|
|
|
it('truncates long input with ellipsis', () => {
|
|
const long = 'x'.repeat(300)
|
|
const result = truncateInput(long, 100)
|
|
expect(result.length).toBe(101) // 100 chars + '…'
|
|
expect(result.endsWith('…')).toBe(true)
|
|
})
|
|
|
|
it('handles objects by stringifying', () => {
|
|
const result = truncateInput({ key: 'value' }, 100)
|
|
expect(result).toContain('key')
|
|
expect(result).toContain('value')
|
|
})
|
|
|
|
it('handles unserializable input', () => {
|
|
const circular: any = {}
|
|
circular.self = circular
|
|
expect(truncateInput(circular, 100)).toBe('(unserializable)')
|
|
})
|
|
})
|
|
|
|
describe('escapeMarkdownV2', () => {
|
|
it('escapes special characters', () => {
|
|
expect(escapeMarkdownV2('hello_world')).toBe('hello\\_world')
|
|
expect(escapeMarkdownV2('a*b*c')).toBe('a\\*b\\*c')
|
|
expect(escapeMarkdownV2('test.md')).toBe('test\\.md')
|
|
})
|
|
|
|
it('leaves plain text unchanged', () => {
|
|
expect(escapeMarkdownV2('hello world')).toBe('hello world')
|
|
})
|
|
})
|
|
|
|
describe('formatImHelp', () => {
|
|
it('lists the lightweight IM commands', () => {
|
|
const text = formatImHelp()
|
|
expect(text).toContain('/new')
|
|
expect(text).toContain('/projects')
|
|
expect(text).toContain('/status')
|
|
expect(text).toContain('/clear')
|
|
expect(text).toContain('/stop')
|
|
expect(text).toContain('/help')
|
|
expect(text).toContain('项目列表')
|
|
expect(text).toContain('/allow <id>')
|
|
})
|
|
})
|
|
|
|
describe('formatImStatus', () => {
|
|
it('formats an active session summary for mobile reading', () => {
|
|
const text = formatImStatus({
|
|
sessionId: 'abc1234567890',
|
|
projectName: 'claude-code-haha',
|
|
branch: 'main',
|
|
model: 'claude-sonnet',
|
|
state: 'tool_executing',
|
|
verb: 'Running tests',
|
|
pendingPermissionCount: 1,
|
|
taskCounts: {
|
|
total: 4,
|
|
pending: 1,
|
|
inProgress: 2,
|
|
completed: 1,
|
|
},
|
|
})
|
|
|
|
expect(text).toContain('项目: claude-code-haha (main)')
|
|
expect(text).toContain('会话: abc12345…')
|
|
expect(text).toContain('模型: claude-sonnet')
|
|
expect(text).toContain('状态: 执行工具中 (Running tests)')
|
|
expect(text).toContain('审批: 1 个待确认')
|
|
expect(text).toContain('任务: 总计 4 · 进行中 2 · 待处理 1 · 已完成 1')
|
|
})
|
|
|
|
it('returns a friendly empty-session message when nothing is active', () => {
|
|
const text = formatImStatus(null)
|
|
expect(text).toContain('当前没有活动会话')
|
|
expect(text).toContain('/new')
|
|
expect(text).toContain('/projects')
|
|
})
|
|
})
|
|
|
|
describe('splitMessageByBytes', () => {
|
|
it('returns a single chunk when the text already fits', () => {
|
|
expect(splitMessageByBytes('hello', 100)).toEqual(['hello'])
|
|
})
|
|
|
|
// The reason this function exists: WeCom caps on UTF-8 bytes, and a
|
|
// character-based split silently overflows the moment the text is Chinese.
|
|
it('keeps every chunk inside the byte budget for CJK text', () => {
|
|
const text = Array.from({ length: 200 }, (_, i) => `第 ${i} 段中文内容。`).join('\n')
|
|
|
|
const chunks = splitMessageByBytes(text, 300)
|
|
|
|
expect(chunks.length).toBeGreaterThan(1)
|
|
for (const chunk of chunks) {
|
|
expect(utf8Length(chunk)).toBeLessThanOrEqual(300)
|
|
}
|
|
expect(chunks.join('\n').replace(/\s+/g, '')).toBe(text.replace(/\s+/g, ''))
|
|
})
|
|
|
|
it('prefers a line boundary over cutting mid-line', () => {
|
|
const chunks = splitMessageByBytes('first line\nsecond line', 12)
|
|
|
|
expect(chunks[0]).toBe('first line')
|
|
})
|
|
|
|
it('never splits a surrogate pair into invalid UTF-8', () => {
|
|
const text = '🙂'.repeat(50)
|
|
|
|
const chunks = splitMessageByBytes(text, 9)
|
|
|
|
for (const chunk of chunks) {
|
|
expect(chunk).not.toContain('\uFFFD')
|
|
expect(Buffer.from(chunk, 'utf8').toString('utf8')).toBe(chunk)
|
|
}
|
|
expect(chunks.join('')).toBe(text)
|
|
})
|
|
|
|
it('makes progress even when a single token exceeds the budget', () => {
|
|
const chunks = splitMessageByBytes('这是一个没有空格的超长中文串', 4)
|
|
|
|
expect(chunks.length).toBeGreaterThan(1)
|
|
expect(chunks.join('')).toBe('这是一个没有空格的超长中文串')
|
|
})
|
|
})
|
|
|
|
describe('utf8Length', () => {
|
|
it('counts bytes, not characters', () => {
|
|
expect(utf8Length('abc')).toBe(3)
|
|
expect(utf8Length('中文')).toBe(6)
|
|
})
|
|
})
|