feat: add reusable core store and OpenClaw state-file adapter skeleton / 新增可重用 state store 與 OpenClaw state-file adapter 骨架
This commit is contained in:
12
package.json
Normal file
12
package.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
14
src/adapters/openclaw-state-file.ts
Normal file
14
src/adapters/openclaw-state-file.ts
Normal 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
14
src/adapters/openclaw.ts
Normal 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,
|
||||||
|
})
|
||||||
|
}
|
||||||
29
src/core/callback-contract.ts
Normal file
29
src/core/callback-contract.ts
Normal 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
13
src/core/policy.ts
Normal 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
32
src/core/state.ts
Normal 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
41
src/core/store.ts
Normal 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
9
src/node-shims.d.ts
vendored
Normal 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
|
||||||
|
}
|
||||||
16
src/telegram/callback-handler.ts
Normal file
16
src/telegram/callback-handler.ts
Normal 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)
|
||||||
|
}
|
||||||
12
src/telegram/reply-decorator.ts
Normal file
12
src/telegram/reply-decorator.ts
Normal 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
39
src/types.ts
Normal 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
|
||||||
|
}
|
||||||
13
tsconfig.json
Normal file
13
tsconfig.json
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"verbatimModuleSyntax": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user