cc-haha/adapters/common/message-dedup.ts
程序员阿江(Relakkes) 82e6e27687 feat: add IM adapter integration (Telegram + Feishu) with web settings UI
Implement IM adapters allowing users to chat with Claude Code from Telegram
and Feishu/Lark. Includes persistent session management (chatId→sessionId
mapping), project selection via /projects command, and a web UI settings page
for configuring bot tokens, allowed users, and default project directory.

Key changes:
- adapters/: Telegram and Feishu adapter scripts with shared common modules
  (WsBridge, MessageBuffer, SessionStore, HttpClient, config, formatting)
- Backend: adapterService + REST API (GET/PUT /api/adapters) with secret masking
- Frontend: AdapterSettings page in Settings tab with i18n support
- DirectoryPicker: use React Portal for dropdown to fix overflow clipping

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 19:38:51 +08:00

58 lines
1.4 KiB
TypeScript

/**
* 消息去重
*
* 防止 WebSocket 重连等场景下消息重复处理。
* 参考 openclaw-lark dedup.ts 的 Map + TTL + 容量 设计。
*/
const DEFAULT_TTL_MS = 10 * 60_000 // 10 minutes
const DEFAULT_MAX_ENTRIES = 5000
const SWEEP_INTERVAL_MS = 60_000 // 1 minute
export class MessageDedup {
private store = new Map<string, number>()
private sweepTimer: ReturnType<typeof setInterval>
constructor(
private ttlMs = DEFAULT_TTL_MS,
private maxEntries = DEFAULT_MAX_ENTRIES,
) {
this.sweepTimer = setInterval(() => this.sweep(), SWEEP_INTERVAL_MS)
}
/** Returns true if this is a NEW message, false if duplicate. */
tryRecord(id: string): boolean {
const now = Date.now()
const existing = this.store.get(id)
if (existing !== undefined && now - existing < this.ttlMs) {
return false // duplicate
}
// Evict oldest if at capacity
if (this.store.size >= this.maxEntries) {
const oldest = this.store.keys().next().value
if (oldest !== undefined) this.store.delete(oldest)
}
this.store.set(id, now)
return true
}
private sweep(): void {
const now = Date.now()
for (const [key, ts] of this.store) {
if (now - ts >= this.ttlMs) {
this.store.delete(key)
} else {
break // Map preserves insertion order; once fresh, rest is fresh
}
}
}
destroy(): void {
clearInterval(this.sweepTimer)
this.store.clear()
}
}