Initial implementation of Telegram AI customer support bot

Cloudflare Workers + Hono + D1 + KV + R2 stack with 4 specialized AI agents
(onboarding, troubleshoot, asset, billing), OpenAI function calling with
7 tool definitions, human escalation, pending action approval workflow,
feedback collection, audit logging, i18n (ko/en), and Workers AI fallback.

43 source files, 45 tests passing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
kappa
2026-02-11 13:21:38 +09:00
commit 1d6b64c9e4
58 changed files with 12857 additions and 0 deletions

View File

@@ -0,0 +1,117 @@
import { describe, it, expect } from 'vitest';
import { CircuitBreaker, CircuitBreakerError, CircuitState } from '../../src/utils/circuit-breaker';
function sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
describe('CircuitBreaker', () => {
it('starts in CLOSED state', () => {
const cb = new CircuitBreaker({ serviceName: 'test' });
expect(cb.getState()).toBe(CircuitState.CLOSED);
});
it('passes through successful executions', async () => {
const cb = new CircuitBreaker({ serviceName: 'test' });
const result = await cb.execute(async () => 'ok');
expect(result).toBe('ok');
expect(cb.getState()).toBe(CircuitState.CLOSED);
});
it('opens after failure threshold is exceeded', async () => {
const cb = new CircuitBreaker({
serviceName: 'test',
failureThreshold: 3,
resetTimeoutMs: 100,
});
for (let i = 0; i < 3; i++) {
await expect(cb.execute(async () => { throw new Error('fail'); })).rejects.toThrow('fail');
}
expect(cb.getState()).toBe(CircuitState.OPEN);
});
it('rejects requests while OPEN', async () => {
const cb = new CircuitBreaker({
serviceName: 'test',
failureThreshold: 2,
resetTimeoutMs: 5000,
});
// Trip the breaker
for (let i = 0; i < 2; i++) {
await expect(cb.execute(async () => { throw new Error('fail'); })).rejects.toThrow();
}
expect(cb.getState()).toBe(CircuitState.OPEN);
// Should throw CircuitBreakerError
await expect(cb.execute(async () => 'ok')).rejects.toThrow(CircuitBreakerError);
});
it('transitions to HALF_OPEN after reset timeout', async () => {
const cb = new CircuitBreaker({
serviceName: 'test',
failureThreshold: 2,
resetTimeoutMs: 100,
});
for (let i = 0; i < 2; i++) {
await expect(cb.execute(async () => { throw new Error('fail'); })).rejects.toThrow();
}
expect(cb.getState()).toBe(CircuitState.OPEN);
await sleep(150);
// Next execute call checks the timeout and transitions to HALF_OPEN
const result = await cb.execute(async () => 'recovered');
expect(result).toBe('recovered');
// Successful test in HALF_OPEN closes the circuit
expect(cb.getState()).toBe(CircuitState.CLOSED);
});
it('closes after successful test in HALF_OPEN', async () => {
const cb = new CircuitBreaker({
serviceName: 'test',
failureThreshold: 2,
resetTimeoutMs: 100,
});
// Open the circuit
for (let i = 0; i < 2; i++) {
await expect(cb.execute(async () => { throw new Error('fail'); })).rejects.toThrow();
}
expect(cb.getState()).toBe(CircuitState.OPEN);
// Wait for reset timeout
await sleep(150);
// Successful execution transitions HALF_OPEN -> CLOSED
await cb.execute(async () => 'success');
expect(cb.getState()).toBe(CircuitState.CLOSED);
// Verify circuit is fully operational again
const result = await cb.execute(async () => 'working');
expect(result).toBe('working');
});
it('re-opens if HALF_OPEN test fails', async () => {
const cb = new CircuitBreaker({
serviceName: 'test',
failureThreshold: 2,
resetTimeoutMs: 100,
});
// Open the circuit
for (let i = 0; i < 2; i++) {
await expect(cb.execute(async () => { throw new Error('fail'); })).rejects.toThrow();
}
await sleep(150);
// Fail during HALF_OPEN
await expect(cb.execute(async () => { throw new Error('still broken'); })).rejects.toThrow();
expect(cb.getState()).toBe(CircuitState.OPEN);
});
});

View File

@@ -0,0 +1,51 @@
import { describe, it, expect } from 'vitest';
import { createLogger, Logger, maskUserId } from '../../src/utils/logger';
describe('Logger', () => {
it('createLogger returns a Logger instance', () => {
const logger = createLogger('test-service');
expect(logger).toBeInstanceOf(Logger);
});
it('info() does not throw', () => {
const logger = createLogger('test-service');
expect(() => logger.info('test message')).not.toThrow();
expect(() => logger.info('with context', { key: 'value' })).not.toThrow();
});
it('warn() does not throw', () => {
const logger = createLogger('test-service');
expect(() => logger.warn('warning message')).not.toThrow();
expect(() => logger.warn('with context', { count: 42 })).not.toThrow();
});
it('error() does not throw', () => {
const logger = createLogger('test-service');
expect(() => logger.error('error message')).not.toThrow();
expect(() => logger.error('with error', new Error('boom'))).not.toThrow();
expect(() => logger.error('full', new Error('boom'), { extra: true })).not.toThrow();
});
});
describe('maskUserId', () => {
it('masks a normal user ID correctly', () => {
expect(maskUserId('821596605')).toBe('8215****');
});
it('masks a short ID (<=4 chars) as all asterisks', () => {
expect(maskUserId('1234')).toBe('****');
expect(maskUserId('abc')).toBe('****');
});
it('returns "unknown" for undefined', () => {
expect(maskUserId(undefined)).toBe('unknown');
});
it('returns "unknown" for empty string', () => {
expect(maskUserId('')).toBe('unknown');
});
it('handles numeric input', () => {
expect(maskUserId(821596605)).toBe('8215****');
});
});

66
tests/utils/retry.test.ts Normal file
View File

@@ -0,0 +1,66 @@
import { describe, it, expect } from 'vitest';
import { retryWithBackoff, RetryError } from '../../src/utils/retry';
describe('retryWithBackoff', () => {
it('succeeds on first attempt without retrying', async () => {
let callCount = 0;
const result = await retryWithBackoff(async () => {
callCount++;
return 'ok';
}, { maxRetries: 3, initialDelayMs: 1, jitter: false });
expect(result).toBe('ok');
expect(callCount).toBe(1);
});
it('retries and succeeds on 2nd attempt', async () => {
let callCount = 0;
const result = await retryWithBackoff(async () => {
callCount++;
if (callCount < 2) throw new Error('transient');
return 'recovered';
}, { maxRetries: 3, initialDelayMs: 1, jitter: false });
expect(result).toBe('recovered');
expect(callCount).toBe(2);
});
it('throws RetryError after all attempts exhausted', async () => {
let callCount = 0;
await expect(
retryWithBackoff(async () => {
callCount++;
throw new Error('permanent');
}, { maxRetries: 2, initialDelayMs: 1, jitter: false })
).rejects.toThrow(RetryError);
// 1 initial + 2 retries = 3 total
expect(callCount).toBe(3);
});
it('respects maxRetries option', async () => {
let callCount = 0;
await expect(
retryWithBackoff(async () => {
callCount++;
throw new Error('fail');
}, { maxRetries: 1, initialDelayMs: 1, jitter: false })
).rejects.toThrow(RetryError);
// 1 initial + 1 retry = 2 total
expect(callCount).toBe(2);
});
it('RetryError contains attempt count and last error', async () => {
try {
await retryWithBackoff(async () => {
throw new Error('specific failure');
}, { maxRetries: 2, initialDelayMs: 1, jitter: false });
} catch (error) {
expect(error).toBeInstanceOf(RetryError);
const retryErr = error as RetryError;
expect(retryErr.attempts).toBe(3);
expect(retryErr.lastError.message).toBe('specific failure');
}
});
});

View File

@@ -0,0 +1,137 @@
import { describe, it, expect, beforeAll, afterEach } from 'vitest';
import { SessionManager, BaseSession } from '../../src/utils/session-manager';
import { createTestUser, getTestDB } from '../setup';
// Use onboarding_sessions table for testing
const manager = new SessionManager<BaseSession>({
tableName: 'onboarding_sessions',
ttlMs: 30 * 60 * 1000, // 30 minutes
maxMessages: 5,
});
describe('SessionManager', () => {
describe('create()', () => {
it('returns session with correct fields', () => {
const session = manager.create('user123', 'greeting');
expect(session.user_id).toBe('user123');
expect(session.status).toBe('greeting');
expect(session.collected_info).toEqual({});
expect(session.messages).toEqual([]);
expect(session.created_at).toBeTypeOf('number');
expect(session.updated_at).toBeTypeOf('number');
expect(session.expires_at).toBeGreaterThan(session.created_at);
});
});
describe('save() and get() round-trip', () => {
it('saves and retrieves session from DB', async () => {
const db = getTestDB();
const session = manager.create('user456', 'gathering');
session.collected_info = { purpose: 'hosting' };
session.messages = [{ role: 'user', content: 'hello' }];
await manager.save(db, session);
const retrieved = await manager.get(db, 'user456');
expect(retrieved).not.toBeNull();
expect(retrieved!.user_id).toBe('user456');
expect(retrieved!.status).toBe('gathering');
expect(retrieved!.collected_info).toEqual({ purpose: 'hosting' });
expect(retrieved!.messages).toEqual([{ role: 'user', content: 'hello' }]);
});
});
describe('delete()', () => {
it('removes session from DB', async () => {
const db = getTestDB();
const session = manager.create('user789', 'greeting');
await manager.save(db, session);
// Verify it exists
const exists = await manager.has(db, 'user789');
expect(exists).toBe(true);
// Delete
await manager.delete(db, 'user789');
// Verify it's gone
const afterDelete = await manager.get(db, 'user789');
expect(afterDelete).toBeNull();
});
});
describe('has()', () => {
it('returns true for existing session', async () => {
const db = getTestDB();
const session = manager.create('user_has_test', 'greeting');
await manager.save(db, session);
expect(await manager.has(db, 'user_has_test')).toBe(true);
});
it('returns false for missing session', async () => {
const db = getTestDB();
expect(await manager.has(db, 'nonexistent_user')).toBe(false);
});
});
describe('addMessage()', () => {
it('adds message to session', () => {
const session = manager.create('user_msg', 'greeting');
manager.addMessage(session, 'user', 'hello');
expect(session.messages).toHaveLength(1);
expect(session.messages[0]).toEqual({ role: 'user', content: 'hello' });
});
it('trims old messages when over limit', () => {
const session = manager.create('user_trim', 'greeting');
// maxMessages is 5, add 7 messages
for (let i = 0; i < 7; i++) {
manager.addMessage(session, 'user', `message ${i}`);
}
expect(session.messages).toHaveLength(5);
// Should keep the last 5 messages (2..6)
expect(session.messages[0].content).toBe('message 2');
expect(session.messages[4].content).toBe('message 6');
});
});
describe('expired sessions', () => {
it('get() returns null for expired session', async () => {
const db = getTestDB();
// Create a session that's already expired
const now = Date.now();
const expiredSession: BaseSession = {
user_id: 'expired_user',
status: 'greeting',
collected_info: {},
messages: [],
created_at: now - 60000,
updated_at: now - 60000,
expires_at: now - 1000, // expired 1 second ago
};
// Insert directly with past expires_at
await db.prepare(
`INSERT INTO onboarding_sessions (user_id, status, collected_info, messages, created_at, updated_at, expires_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`
).bind(
expiredSession.user_id,
expiredSession.status,
JSON.stringify(expiredSession.collected_info),
JSON.stringify(expiredSession.messages),
expiredSession.created_at,
expiredSession.updated_at,
expiredSession.expires_at
).run();
const result = await manager.get(db, 'expired_user');
expect(result).toBeNull();
});
});
});