feat: add reusable core store and OpenClaw state-file adapter skeleton / 新增可重用 state store 與 OpenClaw state-file adapter 骨架

This commit is contained in:
Alice (OpenClaw)
2026-05-13 11:35:15 +08:00
parent d5a7e98fb3
commit ca55445bcb
12 changed files with 244 additions and 0 deletions

View File

@@ -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)
}

14
src/adapters/openclaw.ts Normal file
View File

@@ -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,
})
}

View File

@@ -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,
}
}

13
src/core/policy.ts Normal file
View File

@@ -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 }
}

32
src/core/state.ts Normal file
View File

@@ -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
}

41
src/core/store.ts Normal file
View File

@@ -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
}

9
src/node-shims.d.ts vendored Normal file
View File

@@ -0,0 +1,9 @@
declare module "node:fs" {
const fs: any
export default fs
}
declare module "node:path" {
const path: any
export default path
}

View File

@@ -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)
}

View File

@@ -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" },
],
],
}
}

39
src/types.ts Normal file
View File

@@ -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<string, ReplyEndState>
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
}