Files
修雨 107f3eaf6e feat(connect): add /new /clear slash commands and persist codex session context
Two connector improvements for the linked-bot chat experience, modelled on
codex / OpenClaw connect:

1. Built-in slash commands. A user can type /new (aliases /start, /reset) to
   open a fresh session or /clear to wipe the current one, instead of the
   message being forwarded to the agent. The command set is fixed (never
   dynamically extended) so behaviour stays predictable. Matching requires the
   whole message to be the bare command, so a normal question that merely starts
   with a slash is forwarded untouched. Costs no agent turn / tokens.

   - connect_command.go: channel-agnostic parser + action table (+ tests).
   - sessionResetter optional interface; execForwarder (Claude family) and the
     codex app-server forwarder both implement it. The main loop intercepts a
     command right after the owner-decision interceptor and acks via webhook.

2. Persist codex per-conversation context. codexThreadSessions previously kept
   the convID->threadID map in memory only, so a codex connector restart lost
   every conversation's context — unlike the Claude-family convSessions store
   which already persisted. The thread identity is now the authoritative,
   mutex-guarded map persisted to <config>/connect/<clientId>/codex-threads.json
   (best-effort, atomic write), restored on startup. An empty clientId keeps it
   in memory only, preserving the original behaviour. This also removes a data
   race on threadID by moving it off the per-conversation state into the
   sessions store.
2026-06-24 10:30:04 +08:00

69 lines
3.3 KiB
Go

// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package helpers
import "strings"
// connectControlAction is a built-in slash command a user can type in the chat
// to control the connector's per-conversation session, instead of a message
// that gets forwarded to the agent. Modelled on codex / OpenClaw connect's
// preset commands: the set is fixed (never dynamically extended) so the bot's
// behaviour stays predictable.
type connectControlAction struct {
// name is the canonical action ("new" | "clear"). Both reset the current
// conversation's session today (the next message starts fresh with no prior
// context), but they stay distinct actions so the ack wording can differ and
// a future channel can diverge their behaviour.
name string
// ack is the user-facing confirmation sent back into the chat (Chinese, for
// DingTalk users). It is never forwarded to the agent.
ack string
}
// resetsSession reports whether this action should drop the conversation's
// agent session. Both built-in commands do today; kept as a predicate so adding
// a non-resetting command later does not require touching the call site.
func (a connectControlAction) resetsSession() bool {
return a.name == "new" || a.name == "clear"
}
// connectControlCommands maps the recognised slash tokens to their action. The
// map is the single source of truth for "which commands exist" — both the
// parser and any help text should read from it. "/new", "/start" and "/reset"
// are aliases for opening a fresh session; "/clear" wipes the current one.
var connectControlCommands = map[string]connectControlAction{
"/new": {name: "new", ack: "🆕 已为你开启新会话,之前的上下文不再带入。"},
"/start": {name: "new", ack: "🆕 已为你开启新会话,之前的上下文不再带入。"},
"/reset": {name: "new", ack: "🆕 已为你开启新会话,之前的上下文不再带入。"},
"/clear": {name: "clear", ack: "🧹 已清空当前对话的上下文,我们从头开始。"},
}
// parseConnectControlCommand recognises a built-in slash command. It only
// matches when the WHOLE trimmed message is exactly one known token (case
// insensitive), so a normal question that merely starts with a slash — e.g.
// "/new 这个功能怎么实现?" — is forwarded to the agent untouched rather than
// silently swallowed as a command. Returns (action, true) on a match.
func parseConnectControlCommand(text string) (connectControlAction, bool) {
token := strings.ToLower(strings.TrimSpace(text))
if token == "" || !strings.HasPrefix(token, "/") {
return connectControlAction{}, false
}
// A command is a bare token: reject anything carrying arguments/whitespace.
if strings.ContainsAny(token, " \t\n") {
return connectControlAction{}, false
}
action, ok := connectControlCommands[token]
return action, ok
}