From ca55445bcb6537ff5bf394b531a4e95f751a1fba Mon Sep 17 00:00:00 2001 From: "Alice (OpenClaw)" Date: Wed, 13 May 2026 11:35:15 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20add=20reusable=20core=20store=20and=20O?= =?UTF-8?q?penClaw=20state-file=20adapter=20skeleton=20/=20=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=E5=8F=AF=E9=87=8D=E7=94=A8=20state=20store=20?= =?UTF-8?q?=E8=88=87=20OpenClaw=20state-file=20adapter=20=E9=AA=A8?= =?UTF-8?q?=E6=9E=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 12 +++++++++ src/adapters/openclaw-state-file.ts | 14 ++++++++++ src/adapters/openclaw.ts | 14 ++++++++++ src/core/callback-contract.ts | 29 ++++++++++++++++++++ src/core/policy.ts | 13 +++++++++ src/core/state.ts | 32 ++++++++++++++++++++++ src/core/store.ts | 41 +++++++++++++++++++++++++++++ src/node-shims.d.ts | 9 +++++++ src/telegram/callback-handler.ts | 16 +++++++++++ src/telegram/reply-decorator.ts | 12 +++++++++ src/types.ts | 39 +++++++++++++++++++++++++++ tsconfig.json | 13 +++++++++ 12 files changed, 244 insertions(+) create mode 100644 package.json create mode 100644 src/adapters/openclaw-state-file.ts create mode 100644 src/adapters/openclaw.ts create mode 100644 src/core/callback-contract.ts create mode 100644 src/core/policy.ts create mode 100644 src/core/state.ts create mode 100644 src/core/store.ts create mode 100644 src/node-shims.d.ts create mode 100644 src/telegram/callback-handler.ts create mode 100644 src/telegram/reply-decorator.ts create mode 100644 src/types.ts create mode 100644 tsconfig.json diff --git a/package.json b/package.json new file mode 100644 index 0000000..b52dac4 --- /dev/null +++ b/package.json @@ -0,0 +1,12 @@ +{ + "name": "reply-end-controls", + "version": "0.0.1", + "private": true, + "type": "module", + "scripts": { + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "typescript": "^5.9.3" + } +} diff --git a/src/adapters/openclaw-state-file.ts b/src/adapters/openclaw-state-file.ts new file mode 100644 index 0000000..4b47177 --- /dev/null +++ b/src/adapters/openclaw-state-file.ts @@ -0,0 +1,14 @@ +import type { NormalizedReplyEndEvent, ReplyEndState } from "../types.js" +import { buildReplyEndState } from "../core/state.js" +import { loadReplyEndState, resolveReplyEndStateFile, saveReplyEndState } from "../core/store.js" + +export function persistOpenClawReplyEndState(baseDir: string, event: NormalizedReplyEndEvent) { + const filePath = resolveReplyEndStateFile(baseDir) + const state = buildReplyEndState(event) + return saveReplyEndState(filePath, event.conversationId, state) +} + +export function readOpenClawReplyEndState(baseDir: string, conversationId: string): ReplyEndState | null { + const filePath = resolveReplyEndStateFile(baseDir) + return loadReplyEndState(filePath, conversationId) +} diff --git a/src/adapters/openclaw.ts b/src/adapters/openclaw.ts new file mode 100644 index 0000000..690e5e8 --- /dev/null +++ b/src/adapters/openclaw.ts @@ -0,0 +1,14 @@ +import { buildReplyEndKeyboard } from "../telegram/reply-decorator.js" +import { evaluateReplyEndPolicy } from "../core/policy.js" +import type { ReplyEndState, TelegramInlineKeyboardMarkup } from "../types.js" + +export function buildOpenClawTelegramReplyDecoration(): TelegramInlineKeyboardMarkup { + return buildReplyEndKeyboard() +} + +export function evaluateOpenClawContinuationPolicy(state: ReplyEndState | null, hasTypedUserFollowup: boolean) { + return evaluateReplyEndPolicy({ + state, + hasTypedUserFollowup, + }) +} diff --git a/src/core/callback-contract.ts b/src/core/callback-contract.ts new file mode 100644 index 0000000..3d666e1 --- /dev/null +++ b/src/core/callback-contract.ts @@ -0,0 +1,29 @@ +import type { NormalizedReplyEndEvent, ReplyEndChoice } from "../types.js" + +export function parseTelegramCallbackData(raw: string): ReplyEndChoice | null { + if (raw === "rec:continue") return "continue" + if (raw === "rec:stop") return "stop" + return null +} + +export function normalizeTelegramCallback(params: { + callbackData: string + conversationId: string + sessionKey: string | null + sourceMessageId: string + sourceCallbackId: string + timestamp: string +}): NormalizedReplyEndEvent | null { + const choice = parseTelegramCallbackData(params.callbackData) + if (!choice) return null + + return { + choice, + conversationId: params.conversationId, + sessionKey: params.sessionKey, + sourceMessageId: params.sourceMessageId, + sourceCallbackId: params.sourceCallbackId, + channel: "telegram", + timestamp: params.timestamp, + } +} diff --git a/src/core/policy.ts b/src/core/policy.ts new file mode 100644 index 0000000..1284a57 --- /dev/null +++ b/src/core/policy.ts @@ -0,0 +1,13 @@ +import type { ReplyEndPolicyDecision, ReplyEndPolicyInput } from "../types.js" + +export function evaluateReplyEndPolicy(input: ReplyEndPolicyInput): ReplyEndPolicyDecision { + if (input.hasTypedUserFollowup) { + return { suppressProactiveContinuation: false } + } + + if (input.state?.lastChoice === "stop" && input.state.active) { + return { suppressProactiveContinuation: true } + } + + return { suppressProactiveContinuation: false } +} diff --git a/src/core/state.ts b/src/core/state.ts new file mode 100644 index 0000000..87ff2f0 --- /dev/null +++ b/src/core/state.ts @@ -0,0 +1,32 @@ +import type { NormalizedReplyEndEvent, ReplyEndState, ReplyEndStateMap } from "../types.js" + +export function buildReplyEndState(event: NormalizedReplyEndEvent): ReplyEndState { + return { + lastChoice: event.choice, + lastChoiceAt: event.timestamp, + sourceMessageId: event.sourceMessageId, + sourceCallbackId: event.sourceCallbackId, + active: true, + } +} + +export function clearReplyEndState(): null { + return null +} + +export function setReplyEndState(map: ReplyEndStateMap, conversationId: string, state: ReplyEndState): ReplyEndStateMap { + return { + ...map, + [conversationId]: state, + } +} + +export function getReplyEndState(map: ReplyEndStateMap, conversationId: string): ReplyEndState | null { + return map[conversationId] ?? null +} + +export function clearReplyEndStateForConversation(map: ReplyEndStateMap, conversationId: string): ReplyEndStateMap { + const next = { ...map } + delete next[conversationId] + return next +} diff --git a/src/core/store.ts b/src/core/store.ts new file mode 100644 index 0000000..01d1af4 --- /dev/null +++ b/src/core/store.ts @@ -0,0 +1,41 @@ +import fs from "node:fs" +import path from "node:path" +import type { ReplyEndState, ReplyEndStateMap } from "../types.js" +import { clearReplyEndStateForConversation, getReplyEndState, setReplyEndState } from "./state.js" + +export function resolveReplyEndStateFile(baseDir: string): string { + return path.join(baseDir, "reply-end-controls.json") +} + +export function readReplyEndStateMap(filePath: string): ReplyEndStateMap { + if (!fs.existsSync(filePath)) return {} + try { + const raw = fs.readFileSync(filePath, "utf-8") + const parsed = JSON.parse(raw) + return parsed && typeof parsed === "object" ? parsed as ReplyEndStateMap : {} + } catch { + return {} + } +} + +export function writeReplyEndStateMap(filePath: string, stateMap: ReplyEndStateMap): void { + fs.writeFileSync(filePath, JSON.stringify(stateMap, null, 2)) +} + +export function saveReplyEndState(filePath: string, conversationId: string, state: ReplyEndState): ReplyEndStateMap { + const current = readReplyEndStateMap(filePath) + const next = setReplyEndState(current, conversationId, state) + writeReplyEndStateMap(filePath, next) + return next +} + +export function loadReplyEndState(filePath: string, conversationId: string): ReplyEndState | null { + return getReplyEndState(readReplyEndStateMap(filePath), conversationId) +} + +export function removeReplyEndState(filePath: string, conversationId: string): ReplyEndStateMap { + const current = readReplyEndStateMap(filePath) + const next = clearReplyEndStateForConversation(current, conversationId) + writeReplyEndStateMap(filePath, next) + return next +} diff --git a/src/node-shims.d.ts b/src/node-shims.d.ts new file mode 100644 index 0000000..f116cf5 --- /dev/null +++ b/src/node-shims.d.ts @@ -0,0 +1,9 @@ +declare module "node:fs" { + const fs: any + export default fs +} + +declare module "node:path" { + const path: any + export default path +} diff --git a/src/telegram/callback-handler.ts b/src/telegram/callback-handler.ts new file mode 100644 index 0000000..833b551 --- /dev/null +++ b/src/telegram/callback-handler.ts @@ -0,0 +1,16 @@ +import { buildReplyEndState } from "../core/state.js" +import { normalizeTelegramCallback } from "../core/callback-contract.js" +import type { ReplyEndState } from "../types.js" + +export function handleTelegramReplyEndCallback(params: { + callbackData: string + conversationId: string + sessionKey: string | null + sourceMessageId: string + sourceCallbackId: string + timestamp: string +}): ReplyEndState | null { + const normalized = normalizeTelegramCallback(params) + if (!normalized) return null + return buildReplyEndState(normalized) +} diff --git a/src/telegram/reply-decorator.ts b/src/telegram/reply-decorator.ts new file mode 100644 index 0000000..db4cea2 --- /dev/null +++ b/src/telegram/reply-decorator.ts @@ -0,0 +1,12 @@ +import type { TelegramInlineKeyboardMarkup } from "../types.js" + +export function buildReplyEndKeyboard(): TelegramInlineKeyboardMarkup { + return { + inline_keyboard: [ + [ + { text: "A. 繼續", callback_data: "rec:continue" }, + { text: "B. 就這樣吧,不需要額外處理", callback_data: "rec:stop" }, + ], + ], + } +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..add435c --- /dev/null +++ b/src/types.ts @@ -0,0 +1,39 @@ +export type ReplyEndChoice = "continue" | "stop" + +export type ReplyEndState = { + lastChoice: ReplyEndChoice + lastChoiceAt: string + sourceMessageId: string + sourceCallbackId: string + active: boolean +} + +export type ReplyEndStateMap = Record + +export type NormalizedReplyEndEvent = { + choice: ReplyEndChoice + conversationId: string + sessionKey: string | null + sourceMessageId: string + sourceCallbackId: string + channel: "telegram" + timestamp: string +} + +export type TelegramInlineButton = { + text: string + callback_data: string +} + +export type TelegramInlineKeyboardMarkup = { + inline_keyboard: TelegramInlineButton[][] +} + +export type ReplyEndPolicyInput = { + state: ReplyEndState | null + hasTypedUserFollowup: boolean +} + +export type ReplyEndPolicyDecision = { + suppressProactiveContinuation: boolean +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..0eade3b --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "verbatimModuleSyntax": true + }, + "include": ["src/**/*.ts"] +}