fix(openclaw): stabilize reply polling and tmux injection
This commit is contained in:
@@ -0,0 +1,131 @@
|
||||
import { afterEach, beforeEach, describe, expect, mock, spyOn, test } from "bun:test"
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from "fs"
|
||||
import { tmpdir } from "os"
|
||||
import { join } from "path"
|
||||
import { ReplyListenerRateLimiter } from "../reply-listener-injection"
|
||||
import { pollDiscordReplies } from "../reply-listener-discord"
|
||||
import * as injectionModule from "../reply-listener-injection"
|
||||
import * as sessionRegistryModule from "../session-registry"
|
||||
import type { ReplyListenerDaemonState } from "../reply-listener-state"
|
||||
import type { OpenClawConfig } from "../types"
|
||||
|
||||
const originalHome = process.env.HOME
|
||||
const originalUserProfile = process.env.USERPROFILE
|
||||
|
||||
const tempHome = mkdtempSync(join(tmpdir(), "openclaw-reply-listener-discord-"))
|
||||
const stateDir = join(tempHome, ".omx", "state")
|
||||
const stateFilePath = join(stateDir, "reply-listener-state.json")
|
||||
|
||||
function createConfig(): OpenClawConfig {
|
||||
return {
|
||||
enabled: true,
|
||||
gateways: {
|
||||
gateway: {
|
||||
type: "http",
|
||||
url: "https://example.com",
|
||||
method: "POST",
|
||||
},
|
||||
},
|
||||
hooks: {},
|
||||
replyListener: {
|
||||
discordBotToken: "discord-token",
|
||||
discordChannelId: "channel-1",
|
||||
authorizedDiscordUserIds: ["user-1"],
|
||||
pollIntervalMs: 10,
|
||||
rateLimitPerMinute: 10,
|
||||
maxMessageLength: 500,
|
||||
includePrefix: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function createState(): ReplyListenerDaemonState {
|
||||
return {
|
||||
isRunning: true,
|
||||
pid: 1234,
|
||||
startedAt: "2026-04-07T00:00:00.000Z",
|
||||
startupToken: "startup-token",
|
||||
configSignature: null,
|
||||
lastPollAt: "2026-04-07T00:00:01.000Z",
|
||||
telegramLastUpdateId: null,
|
||||
discordLastMessageId: null,
|
||||
lastDiscordMessageId: null,
|
||||
messagesSeen: 0,
|
||||
messagesInjected: 0,
|
||||
errors: 0,
|
||||
}
|
||||
}
|
||||
|
||||
describe("pollDiscordReplies", () => {
|
||||
beforeEach(() => {
|
||||
process.env.HOME = tempHome
|
||||
process.env.USERPROFILE = tempHome
|
||||
rmSync(stateDir, { recursive: true, force: true })
|
||||
mkdirSync(stateDir, { recursive: true })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
mock.restore()
|
||||
})
|
||||
|
||||
test("records HTTP failures in daemon state when Discord returns non-ok", async () => {
|
||||
const fetchSpy = spyOn(globalThis, "fetch").mockResolvedValue(
|
||||
new Response("unauthorized", {
|
||||
status: 401,
|
||||
}),
|
||||
)
|
||||
|
||||
const state = createState()
|
||||
|
||||
await pollDiscordReplies(createConfig(), state, new ReplyListenerRateLimiter(10))
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1)
|
||||
expect(state.errors).toBe(1)
|
||||
expect(state.lastError).toBe("Discord API error: HTTP 401")
|
||||
expect(existsSync(stateFilePath)).toBe(true)
|
||||
|
||||
const persistedState = JSON.parse(readFileSync(stateFilePath, "utf-8")) as ReplyListenerDaemonState
|
||||
expect(persistedState.errors).toBe(1)
|
||||
expect(persistedState.lastError).toBe("Discord API error: HTTP 401")
|
||||
expect(persistedState.messagesSeen).toBe(0)
|
||||
})
|
||||
|
||||
test("increments messagesInjected when a Discord reply matches a registered message", async () => {
|
||||
const fetchSpy = spyOn(globalThis, "fetch")
|
||||
.mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify([
|
||||
{
|
||||
id: "incoming-1",
|
||||
content: "Ship it",
|
||||
author: { id: "user-1" },
|
||||
message_reference: { message_id: "outbound-1" },
|
||||
},
|
||||
]),
|
||||
{ status: 200 },
|
||||
),
|
||||
)
|
||||
.mockResolvedValueOnce(new Response(null, { status: 204 }))
|
||||
const lookupSpy = spyOn(sessionRegistryModule, "lookupByMessageId").mockReturnValue({
|
||||
sessionId: "ses-1",
|
||||
tmuxSession: "session-1",
|
||||
tmuxPaneId: "%7",
|
||||
projectPath: "/tmp/project",
|
||||
platform: "discord-bot",
|
||||
messageId: "outbound-1",
|
||||
createdAt: "2026-04-07T00:00:00.000Z",
|
||||
})
|
||||
const injectSpy = spyOn(injectionModule, "injectReplyIntoPane").mockResolvedValue(true)
|
||||
|
||||
const state = createState()
|
||||
|
||||
await pollDiscordReplies(createConfig(), state, new ReplyListenerRateLimiter(10))
|
||||
|
||||
expect(lookupSpy).toHaveBeenCalledWith("discord-bot", "outbound-1")
|
||||
expect(injectSpy).toHaveBeenCalledWith("%7", "Ship it", "discord", createConfig())
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(2)
|
||||
expect(state.messagesSeen).toBe(1)
|
||||
expect(state.messagesInjected).toBe(1)
|
||||
expect(state.lastDiscordMessageId).toBe("incoming-1")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,110 @@
|
||||
import { lookupByMessageId } from "./session-registry"
|
||||
import { injectReplyIntoPane, ReplyListenerRateLimiter } from "./reply-listener-injection"
|
||||
import { logReplyListenerMessage } from "./reply-listener-log"
|
||||
import {
|
||||
recordSeenDiscordMessage,
|
||||
writeReplyListenerDaemonState,
|
||||
type ReplyListenerDaemonState,
|
||||
} from "./reply-listener-state"
|
||||
import type { OpenClawConfig } from "./types"
|
||||
|
||||
interface DiscordMessage {
|
||||
id: string
|
||||
content: string
|
||||
author: { id: string }
|
||||
message_reference?: { message_id?: string }
|
||||
}
|
||||
|
||||
let discordBackoffUntil = 0
|
||||
|
||||
export async function pollDiscordReplies(
|
||||
config: OpenClawConfig,
|
||||
state: ReplyListenerDaemonState,
|
||||
rateLimiter: ReplyListenerRateLimiter,
|
||||
): Promise<void> {
|
||||
const replyListener = config.replyListener
|
||||
if (!replyListener?.discordBotToken || !replyListener.discordChannelId) return
|
||||
if (!replyListener.authorizedDiscordUserIds || replyListener.authorizedDiscordUserIds.length === 0) {
|
||||
return
|
||||
}
|
||||
if (Date.now() < discordBackoffUntil) return
|
||||
|
||||
try {
|
||||
const after = state.discordLastMessageId
|
||||
? `?after=${state.discordLastMessageId}&limit=10`
|
||||
: "?limit=10"
|
||||
const url = `https://discord.com/api/v10/channels/${replyListener.discordChannelId}/messages${after}`
|
||||
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => controller.abort(), 10000)
|
||||
const response = await fetch(url, {
|
||||
method: "GET",
|
||||
headers: { Authorization: `Bot ${replyListener.discordBotToken}` },
|
||||
signal: controller.signal,
|
||||
})
|
||||
clearTimeout(timeout)
|
||||
|
||||
const remaining = response.headers.get("x-ratelimit-remaining")
|
||||
const reset = response.headers.get("x-ratelimit-reset")
|
||||
if (remaining !== null && Number.parseInt(remaining, 10) < 2) {
|
||||
const parsedReset = reset ? Number.parseFloat(reset) : Number.NaN
|
||||
const resetTime = Number.isFinite(parsedReset) ? parsedReset * 1000 : Date.now() + 10000
|
||||
discordBackoffUntil = resetTime
|
||||
logReplyListenerMessage(
|
||||
`WARN: Discord rate limit low (remaining: ${remaining}), backing off until ${new Date(resetTime).toISOString()}`,
|
||||
)
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
state.errors += 1
|
||||
state.lastError = `Discord API error: HTTP ${response.status}`
|
||||
logReplyListenerMessage(state.lastError)
|
||||
writeReplyListenerDaemonState(state)
|
||||
return
|
||||
}
|
||||
|
||||
const messages = await response.json()
|
||||
if (!Array.isArray(messages) || messages.length === 0) return
|
||||
|
||||
for (const message of [...messages as DiscordMessage[]].reverse()) {
|
||||
recordSeenDiscordMessage(state, message.id)
|
||||
writeReplyListenerDaemonState(state)
|
||||
|
||||
const replyToMessageId = message.message_reference?.message_id
|
||||
if (!replyToMessageId) continue
|
||||
if (!replyListener.authorizedDiscordUserIds.includes(message.author.id)) continue
|
||||
|
||||
const mapping = lookupByMessageId("discord-bot", replyToMessageId)
|
||||
if (!mapping) continue
|
||||
|
||||
if (!rateLimiter.canProceed()) {
|
||||
logReplyListenerMessage(`WARN: Rate limit exceeded, dropping Discord message ${message.id}`)
|
||||
state.errors += 1
|
||||
continue
|
||||
}
|
||||
|
||||
const success = await injectReplyIntoPane(mapping.tmuxPaneId, message.content, "discord", config)
|
||||
if (success) {
|
||||
state.messagesInjected += 1
|
||||
try {
|
||||
await fetch(
|
||||
`https://discord.com/api/v10/channels/${replyListener.discordChannelId}/messages/${message.id}/reactions/%E2%9C%85/@me`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { Authorization: `Bot ${replyListener.discordBotToken}` },
|
||||
},
|
||||
)
|
||||
} catch {
|
||||
}
|
||||
} else {
|
||||
state.errors += 1
|
||||
}
|
||||
|
||||
writeReplyListenerDaemonState(state)
|
||||
}
|
||||
} catch (error) {
|
||||
state.errors += 1
|
||||
state.lastError = error instanceof Error ? error.message : String(error)
|
||||
logReplyListenerMessage(`Discord polling error: ${state.lastError}`)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { removeMessagesByPane } from "./session-registry"
|
||||
import { analyzePaneContent, captureTmuxPane, sendToPane } from "./tmux"
|
||||
import { logReplyListenerMessage } from "./reply-listener-log"
|
||||
import type { OpenClawConfig } from "./types"
|
||||
|
||||
export function sanitizeReplyInput(text: string): string {
|
||||
return text
|
||||
.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, "")
|
||||
.replace(/[\u200e\u200f\u202a-\u202e\u2066-\u2069]/g, "")
|
||||
.replace(/\r?\n/g, " ")
|
||||
.replace(/\\/g, "\\\\")
|
||||
.replace(/`/g, "\\`")
|
||||
.replace(/\$\(/g, "\\$(")
|
||||
.replace(/\$\{/g, "\\${")
|
||||
.trim()
|
||||
}
|
||||
|
||||
export class ReplyListenerRateLimiter {
|
||||
private readonly maxPerMinute: number
|
||||
private readonly timestamps: number[] = []
|
||||
private readonly windowMs = 60 * 1000
|
||||
|
||||
constructor(maxPerMinute: number) {
|
||||
this.maxPerMinute = maxPerMinute
|
||||
}
|
||||
|
||||
canProceed(): boolean {
|
||||
const now = Date.now()
|
||||
const recent = this.timestamps.filter((timestamp) => now - timestamp < this.windowMs)
|
||||
this.timestamps.length = 0
|
||||
this.timestamps.push(...recent)
|
||||
|
||||
if (this.timestamps.length >= this.maxPerMinute) {
|
||||
return false
|
||||
}
|
||||
|
||||
this.timestamps.push(now)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
export async function injectReplyIntoPane(
|
||||
paneId: string,
|
||||
text: string,
|
||||
platform: string,
|
||||
config: OpenClawConfig,
|
||||
): Promise<boolean> {
|
||||
const replyListener = config.replyListener
|
||||
const content = await captureTmuxPane(paneId, 15)
|
||||
const analysis = analyzePaneContent(content)
|
||||
|
||||
if (analysis.confidence < 0.3) {
|
||||
logReplyListenerMessage(
|
||||
`WARN: Pane ${paneId} does not appear to be running OpenCode CLI (confidence: ${analysis.confidence}). Skipping injection, removing stale mapping.`,
|
||||
)
|
||||
removeMessagesByPane(paneId)
|
||||
return false
|
||||
}
|
||||
|
||||
const prefix = replyListener?.includePrefix === false ? "" : `[reply:${platform}] `
|
||||
const sanitized = sanitizeReplyInput(prefix + text)
|
||||
const truncated = sanitized.slice(0, replyListener?.maxMessageLength ?? 500)
|
||||
const success = await sendToPane(paneId, truncated, true)
|
||||
|
||||
if (success) {
|
||||
logReplyListenerMessage(
|
||||
`Injected reply from ${platform} into pane ${paneId}: "${truncated.slice(0, 50)}${truncated.length > 50 ? "..." : ""}"`,
|
||||
)
|
||||
} else {
|
||||
logReplyListenerMessage(`ERROR: Failed to inject reply into pane ${paneId}`)
|
||||
}
|
||||
|
||||
return success
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { lookupByMessageId } from "./session-registry"
|
||||
import { injectReplyIntoPane, ReplyListenerRateLimiter } from "./reply-listener-injection"
|
||||
import { logReplyListenerMessage } from "./reply-listener-log"
|
||||
import { writeReplyListenerDaemonState, type ReplyListenerDaemonState } from "./reply-listener-state"
|
||||
import type { OpenClawConfig } from "./types"
|
||||
|
||||
interface TelegramMessage {
|
||||
message_id?: number
|
||||
chat?: { id?: number | string }
|
||||
text?: string
|
||||
reply_to_message?: { message_id?: number }
|
||||
}
|
||||
|
||||
interface TelegramUpdate {
|
||||
update_id?: number
|
||||
message?: TelegramMessage
|
||||
}
|
||||
|
||||
function parseTelegramUpdatesResponse(body: unknown): TelegramUpdate[] {
|
||||
if (typeof body !== "object" || body === null) return []
|
||||
const result = (body as { result?: TelegramUpdate[] }).result
|
||||
return Array.isArray(result) ? result : []
|
||||
}
|
||||
|
||||
export async function pollTelegramReplies(
|
||||
config: OpenClawConfig,
|
||||
state: ReplyListenerDaemonState,
|
||||
rateLimiter: ReplyListenerRateLimiter,
|
||||
): Promise<void> {
|
||||
const replyListener = config.replyListener
|
||||
if (!replyListener?.telegramBotToken || !replyListener.telegramChatId) return
|
||||
|
||||
try {
|
||||
const offset = state.telegramLastUpdateId ? state.telegramLastUpdateId + 1 : 0
|
||||
const url = `https://api.telegram.org/bot${replyListener.telegramBotToken}/getUpdates?offset=${offset}&timeout=0`
|
||||
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => controller.abort(), 10000)
|
||||
const response = await fetch(url, { method: "GET", signal: controller.signal })
|
||||
clearTimeout(timeout)
|
||||
|
||||
if (!response.ok) {
|
||||
logReplyListenerMessage(`Telegram API error: HTTP ${response.status}`)
|
||||
return
|
||||
}
|
||||
|
||||
const updates = parseTelegramUpdatesResponse(await response.json())
|
||||
for (const update of updates) {
|
||||
const message = update.message
|
||||
state.telegramLastUpdateId = update.update_id ?? state.telegramLastUpdateId
|
||||
writeReplyListenerDaemonState(state)
|
||||
|
||||
if (!message?.reply_to_message?.message_id) continue
|
||||
if (String(message.chat?.id) !== replyListener.telegramChatId) continue
|
||||
if (!message.text) continue
|
||||
|
||||
const mapping = lookupByMessageId("telegram", String(message.reply_to_message.message_id))
|
||||
if (!mapping) continue
|
||||
|
||||
if (!rateLimiter.canProceed()) {
|
||||
logReplyListenerMessage(`WARN: Rate limit exceeded, dropping Telegram message ${message.message_id}`)
|
||||
state.errors += 1
|
||||
continue
|
||||
}
|
||||
|
||||
const success = await injectReplyIntoPane(mapping.tmuxPaneId, message.text, "telegram", config)
|
||||
if (success) {
|
||||
state.messagesInjected += 1
|
||||
try {
|
||||
await fetch(`https://api.telegram.org/bot${replyListener.telegramBotToken}/sendMessage`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
chat_id: replyListener.telegramChatId,
|
||||
text: "Injected into Codex CLI session.",
|
||||
reply_to_message_id: message.message_id,
|
||||
}),
|
||||
})
|
||||
} catch {
|
||||
}
|
||||
} else {
|
||||
state.errors += 1
|
||||
}
|
||||
|
||||
writeReplyListenerDaemonState(state)
|
||||
}
|
||||
} catch (error) {
|
||||
state.errors += 1
|
||||
state.lastError = error instanceof Error ? error.message : String(error)
|
||||
logReplyListenerMessage(`Telegram polling error: ${state.lastError}`)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user