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