1607 lines
69 KiB
Go
1607 lines
69 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 (
|
||
"context"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"os"
|
||
"os/exec"
|
||
"path/filepath"
|
||
"regexp"
|
||
"strconv"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
|
||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||
"github.com/google/uuid"
|
||
"github.com/open-dingtalk/dingtalk-stream-sdk-go/card"
|
||
"github.com/open-dingtalk/dingtalk-stream-sdk-go/chatbot"
|
||
"github.com/open-dingtalk/dingtalk-stream-sdk-go/client"
|
||
sdklogger "github.com/open-dingtalk/dingtalk-stream-sdk-go/logger"
|
||
)
|
||
|
||
// streamSDKLogger surfaces the Stream SDK's connection lifecycle on stderr.
|
||
// The SDK's default logger is a doNothingLogger — without SetLogger the
|
||
// operator cannot see "connect success", reconnects or read errors, making a
|
||
// dead connector indistinguishable from a healthy idle one. Debug frames stay
|
||
// silent (they dump every ping/pong).
|
||
type streamSDKLogger struct{}
|
||
|
||
func (streamSDKLogger) Debugf(format string, args ...interface{}) {}
|
||
func (streamSDKLogger) Infof(format string, args ...interface{}) {
|
||
fmt.Fprintf(os.Stderr, "[stream] "+format+"\n", args...)
|
||
}
|
||
func (streamSDKLogger) Warningf(format string, args ...interface{}) {
|
||
fmt.Fprintf(os.Stderr, "[stream][warn] "+format+"\n", args...)
|
||
}
|
||
func (streamSDKLogger) Errorf(format string, args ...interface{}) {
|
||
fmt.Fprintf(os.Stderr, "[stream][error] "+format+"\n", args...)
|
||
}
|
||
func (streamSDKLogger) Fatalf(format string, args ...interface{}) {
|
||
fmt.Fprintf(os.Stderr, "[stream][fatal] "+format+"\n", args...)
|
||
}
|
||
|
||
var streamLoggerOnce sync.Once
|
||
|
||
// forwarder feeds one user message to a channel's local agent and returns its
|
||
// reply. Every stream-bridge channel forwards to a local agent CLI (one-shot,
|
||
// runs 24/7), so the Stream main loop stays channel-agnostic. convID is the
|
||
// DingTalk conversation the message belongs to; forwarders with session memory
|
||
// use it to resume the same agent session, so follow-up questions keep context.
|
||
type forwarder interface {
|
||
forward(ctx context.Context, convID, text string) (string, error)
|
||
label() string
|
||
}
|
||
|
||
type streamingForwarder interface {
|
||
forwarder
|
||
canStream() bool
|
||
forwardStream(ctx context.Context, convID, text string, onDelta func(string)) (string, error)
|
||
}
|
||
|
||
// sessionResetter is an optional capability: a forwarder that can forget a
|
||
// conversation's agent session, so a built-in /new or /clear command starts a
|
||
// fresh context. Forwarders with per-conversation memory (Claude-family exec,
|
||
// codex app-server) implement it; a stateless channel does not, and the main
|
||
// loop tells the user the command is unsupported there.
|
||
type sessionResetter interface {
|
||
resetSession(convID string)
|
||
}
|
||
|
||
// sessionClearer is an optional capability beyond sessionResetter: a forwarder
|
||
// that can actively dispose a conversation's session on the agent side (e.g.
|
||
// opencode's DELETE /session/:id), so /clear truly wipes it instead of only
|
||
// forgetting the local id mapping. Channels whose agent exposes no delete in
|
||
// the mode DWS drives it fall back to sessionResetter, so /clear behaves like
|
||
// /new there.
|
||
type sessionClearer interface {
|
||
clearSession(ctx context.Context, convID string) error
|
||
}
|
||
|
||
type forwarderCloser interface {
|
||
close() error
|
||
}
|
||
|
||
// connectAgentOptions carries the user-facing agent tuning exposed on
|
||
// `dev connect` (and mirrored env vars). Zero value = defaults:
|
||
// channel's built-in model, per-conversation memory on (where the CLI supports
|
||
// it), empty scratch workdir.
|
||
type connectAgentOptions struct {
|
||
// Model overrides the channel CLI's model (flag --agent-model /
|
||
// env DWS_AGENT_MODEL). Empty keeps the spec's built-in choice.
|
||
Model string
|
||
// WorkDir is the directory the agent CLI runs from (flag --agent-workdir /
|
||
// env DWS_AGENT_WORKDIR). Pointing it at a directory with knowledge files
|
||
// (e.g. a CLAUDE.md) gives the bot context; empty uses a clean temp dir,
|
||
// which keeps cold-start fast (a large $HOME costs ~29s vs ~4s).
|
||
WorkDir string
|
||
// Memory enables per-conversation session resume on channels whose CLI
|
||
// supports addressable sessions (--session-id/--resume: claudecode,
|
||
// codebuddy/workbuddy). Disable with --agent-memory=false.
|
||
Memory bool
|
||
// ReplyCard answers with a DingTalk AI card (thinking → done states, like
|
||
// the hermes/openclaw official pipelines) instead of a plain message.
|
||
// Any card failure falls back to the plain webhook reply. Disable with
|
||
// --reply-card=false.
|
||
ReplyCard bool
|
||
// CardTemplate is the AI-card template ID (--card-template /
|
||
// DWS_CARD_TEMPLATE). Card templates are app-scoped: register one under
|
||
// your app in the developer console for reliable branded rendering. Empty
|
||
// means no cards — replies stay plain text/markdown with the Thinking/Done
|
||
// chips (see newAICardClient); pass "public" to opt into the shared
|
||
// openclaw template (best-effort: shared templates may not render for
|
||
// every app).
|
||
CardTemplate string
|
||
// KnowledgeDir is a local directory of .md/.txt answering material
|
||
// (--knowledge-dir / DWS_KNOWLEDGE_DIR): per-message top-k retrieval is
|
||
// prepended to the prompt while the agent keeps running from the clean
|
||
// scratch dir (see connect_knowledge.go). Empty = off.
|
||
KnowledgeDir string
|
||
// KnowledgeSource is a typed knowledge source (--knowledge-source):
|
||
// "wiki:<spaceId>" / "doc:<docId>" pulls a DingTalk knowledge base on
|
||
// startup, caches it locally, and feeds it into the same retriever as
|
||
// KnowledgeDir (see connect_knowledge_wiki.go). A bare value is treated as
|
||
// a local directory. Empty = off; coexists with KnowledgeDir.
|
||
KnowledgeSource string
|
||
// AllowedUsers / AllowedGroups are staffId / openConversationId
|
||
// allowlists (--allowed-users / --allowed-groups, comma-separated; env
|
||
// DWS_ALLOWED_USERS / DWS_ALLOWED_GROUPS). Empty = everyone.
|
||
AllowedUsers []string
|
||
AllowedGroups []string
|
||
// UserRateLimit caps messages per sender per minute
|
||
// (--user-rate-limit / DWS_USER_RATE_LIMIT); 0 = unlimited.
|
||
UserRateLimit int
|
||
// OwnerUserID is the digital-twin owner's staffId (--owner-user-id /
|
||
// DWS_OWNER_USER_ID). When set together with an interactive-card template
|
||
// (ApprovalCardTemplate), the confirmation gate is active: action requests
|
||
// are routed to this user for [Approve]/[Reject] before executing. Empty =
|
||
// gate off (plain Q&A).
|
||
OwnerUserID string
|
||
// ApprovalCardTemplate is the interactive-card template ID for the approval
|
||
// card (--approval-card-template / DWS_APPROVAL_CARD_TEMPLATE). Required for
|
||
// the gate to render [Approve]/[Reject] buttons; empty = gate off.
|
||
ApprovalCardTemplate string
|
||
// RoleConfigPath is a digital-employee role YAML (--role-config /
|
||
// DWS_ROLE_CONFIG). When set, the role's owner / persona / knowledge sources
|
||
// fill the corresponding options that were not given explicitly (an explicit
|
||
// flag/env always wins). The role's client_id must match the connecting bot.
|
||
// See connect_role.go for the schema. Empty = no role profile.
|
||
RoleConfigPath string
|
||
// Persona is a system-prompt fragment prepended to every forwarded prompt to
|
||
// shape the bot's tone/expertise. Normally sourced from the role config's
|
||
// `persona`; there is no standalone flag for it. Empty = no persona prefix.
|
||
Persona string
|
||
// KnowledgeSources are additional typed knowledge sources (same grammar as
|
||
// KnowledgeSource) merged into the retriever on startup. Sourced from the
|
||
// role config's `knowledge_sources`; each is loaded and its chunks appended
|
||
// to the same knowledge base as KnowledgeDir/KnowledgeSource. Empty = none.
|
||
KnowledgeSources []string
|
||
// AuditSheetNode / AuditSheetTab point the approval gate's audit trail at a
|
||
// DingTalk online sheet (axls): one row per action (--audit-sheet /
|
||
// DWS_AUDIT_SHEET is the doc id/URL, --audit-sheet-tab the worksheet, default
|
||
// "Sheet1"). Empty AuditSheetNode = local approvals JSON only.
|
||
AuditSheetNode string
|
||
AuditSheetTab string
|
||
// RoleScopes is the role's capability allowlist (RoleConfig.AllowedScopes),
|
||
// e.g. ["todo", "approval"]. When non-empty the approval gate refuses an
|
||
// action whose product is not in the list, so a role stays in its lane (an
|
||
// HR assistant can't touch code/drive). Empty = no scope restriction.
|
||
RoleScopes []string
|
||
// ConfirmPolicy is the role's confirmation strategy for OTHERS' action
|
||
// requests (the owner's own requests always auto-run): "manual" asks the
|
||
// owner every time, "auto" runs without asking (full trust, still audited),
|
||
// "remember" asks once per action kind then reuses that decision. Sourced
|
||
// from RoleConfig.confirm_policy; empty = manual.
|
||
ConfirmPolicy string
|
||
// Timeout caps each agent turn (--agent-timeout seconds /
|
||
// DWS_AGENT_TIMEOUT_MS milliseconds). 0 = no limit (default).
|
||
Timeout time.Duration
|
||
// Yolo enables highest-permission mode for the agent. It is the default for
|
||
// dev connect; pass --agent-permission-mode ask or --agent-approval-mode ask
|
||
// to opt into the restricted/confirmation mode. Each channel maps yolo to
|
||
// its own permission flag:
|
||
// Claude Code / codebuddy / workbuddy get --dangerously-skip-permissions;
|
||
// Codex switches sandbox from read-only to workspace-write; Qoder
|
||
// re-enables skills and user settings. Use with caution.
|
||
Yolo bool
|
||
}
|
||
|
||
// isStreamBridgeChannel reports whether a channel is wired through the Go-native
|
||
// Stream + exec forwarder path (i.e. it has an agent spec). openclaw (external
|
||
// connector) and hermes (official channel) are not.
|
||
func isStreamBridgeChannel(channel string) bool {
|
||
_, ok := agentSpecs[channel]
|
||
return ok
|
||
}
|
||
|
||
// execForwarder invokes a local agent CLI: fixed argv plus the message text as
|
||
// the trailing argument, returning stdout. Used by claudecode / codebuddy and
|
||
// generic/custom one-shot channels.
|
||
// env holds extra environment entries appended to os.Environ() (e.g. codebuddy's
|
||
// CODEBUDDY_CONFIG_DIR so it reuses the WorkBuddy login).
|
||
type execForwarder struct {
|
||
name string
|
||
argv []string
|
||
env []string
|
||
timeout time.Duration
|
||
// workDir is where the agent CLI runs from; empty falls back to the clean
|
||
// scratch dir (see connectWorkDir).
|
||
workDir string
|
||
// sessions, when non-nil, maps DingTalk conversations to agent session IDs
|
||
// for CLIs with addressable sessions (--session-id / --resume). nil =
|
||
// stateless one-shot per message.
|
||
sessions *convSessions
|
||
// streamArgv, when non-empty, is the incremental-output argv (bin first),
|
||
// with parser naming its stdout protocol (see connect_streaming.go).
|
||
streamArgv []string
|
||
parser string
|
||
}
|
||
|
||
// resetSession drops the conversation's agent session so the next message
|
||
// starts a fresh one. Implements sessionResetter for the built-in /new and
|
||
// /clear commands. A no-op when this channel has no addressable sessions
|
||
// (sessions == nil).
|
||
func (f *execForwarder) resetSession(convID string) {
|
||
if f.sessions != nil && strings.TrimSpace(convID) != "" {
|
||
f.sessions.reset(convID)
|
||
}
|
||
}
|
||
|
||
func (f *execForwarder) label() string {
|
||
memo := "stateless"
|
||
if f.sessions != nil {
|
||
memo = "session-memory"
|
||
}
|
||
return fmt.Sprintf("exec:%s (%s, %s)", f.name, f.argv[0], memo)
|
||
}
|
||
|
||
func (f *execForwarder) hasSession(convID string) bool {
|
||
return f.sessions != nil && strings.TrimSpace(convID) != ""
|
||
}
|
||
|
||
func (f *execForwarder) commandArgs(argv []string, convID, text string) []string {
|
||
// Session args go right after the binary, before the spec tail — some specs
|
||
// (qoder) end the tail with `-p` so the prompt must stay the trailing
|
||
// positional argument.
|
||
var args []string
|
||
if f.hasSession(convID) {
|
||
args = append(args, f.sessions.args(convID)...)
|
||
}
|
||
args = append(args, argv[1:]...)
|
||
args = append(args, text)
|
||
return args
|
||
}
|
||
|
||
func (f *execForwarder) configureCommand(cmd *exec.Cmd) {
|
||
// Run the agent CLI from a clean, empty directory rather than inheriting the
|
||
// connector's CWD (often $HOME). Some agents scan the working tree / nearby
|
||
// config on startup — e.g. `claude -p` takes ~29s from a large $HOME but ~4s
|
||
// from an empty dir. A slow reply misses DingTalk's AI-assistant response
|
||
// window and leaves the card stuck on "数据加载中", so keeping the forward
|
||
// fast is what makes the reply actually render. --agent-workdir trades that
|
||
// speed for context (knowledge files in the workdir).
|
||
if f.workDir != "" {
|
||
cmd.Dir = f.workDir
|
||
} else {
|
||
cmd.Dir = connectWorkDir()
|
||
}
|
||
if len(f.env) > 0 {
|
||
cmd.Env = append(os.Environ(), f.env...)
|
||
}
|
||
}
|
||
|
||
func (f *execForwarder) forward(ctx context.Context, convID, text string) (string, error) {
|
||
ctx, cancel := applyTimeout(ctx, f.timeout)
|
||
defer cancel()
|
||
|
||
run := func() (string, string, error) {
|
||
args := f.commandArgs(f.argv, convID, text)
|
||
cmd := exec.CommandContext(ctx, f.argv[0], args...)
|
||
f.configureCommand(cmd)
|
||
out, err := cmd.Output()
|
||
s := strings.TrimSpace(string(out))
|
||
// Guard against a backend error being mistaken for the answer: some agent
|
||
// CLIs (claude) print "API Error: 4xx ..." to stdout and still exit 0, so a
|
||
// non-empty stdout is not proof of a real reply. If stdout is a bare backend
|
||
// error, return an actionable hint instead of forwarding the raw error into
|
||
// the chat (issue #14: a custom-provider 422 was echoed to the group).
|
||
if s != "" && !agentReplyIsError(s) {
|
||
return brandReply(f.name, s), "", nil
|
||
}
|
||
if s != "" && agentReplyIsError(s) {
|
||
if f.hasSession(convID) {
|
||
f.sessions.reset(convID)
|
||
}
|
||
return agentBackendErrorReply(s), "", nil
|
||
}
|
||
if err != nil {
|
||
msg := execErrorMessage(err)
|
||
return "", msg, err
|
||
}
|
||
return "(本地 agent 无文本输出)", "", nil
|
||
}
|
||
|
||
reply, msg, err := run()
|
||
if err == nil {
|
||
return reply, nil
|
||
}
|
||
|
||
if f.hasSession(convID) && agentSessionMissingError(msg) {
|
||
f.sessions.reset(convID)
|
||
reply, msg, err = run()
|
||
if err == nil {
|
||
return reply, nil
|
||
}
|
||
}
|
||
|
||
if f.hasSession(convID) {
|
||
// Self-heal session state: if this conversation's session is broken
|
||
// (e.g. --resume of a session that was never created or got cleaned),
|
||
// drop the mapping so the next message starts a fresh session instead
|
||
// of failing forever.
|
||
f.sessions.reset(convID)
|
||
}
|
||
return "", fmt.Errorf("本地 %s agent 调用失败:%s", f.name, truncateRunes(msg, 300))
|
||
}
|
||
|
||
// convSessions maps a DingTalk conversation to a stable agent session ID, so a
|
||
// channel CLI with addressable sessions keeps multi-turn context per chat.
|
||
// First message of a conversation mints a UUID and passes `--session-id <id>`
|
||
// (create); subsequent messages pass `--resume <id>` (continue). Claude Code,
|
||
// codebuddy and qodercli share these exact flags in one-shot CLI mode. Qoder's
|
||
// persistent stream-json mode reuses the same UUID values as JSON session_id.
|
||
//
|
||
// The map is persisted to disk (path, scoped per clientId) so a connector
|
||
// restart resumes every chat's context instead of starting fresh — the whole
|
||
// point of an always-on digital employee. An empty path keeps the map purely
|
||
// in memory (persistence disabled, e.g. --agent-memory off or no clientId),
|
||
// preserving the original behaviour exactly. Persistence is best-effort: a
|
||
// failed save only logs a warning and never blocks message handling.
|
||
type convSessions struct {
|
||
mu sync.Mutex
|
||
m map[string]string
|
||
path string // on-disk store; empty disables persistence
|
||
}
|
||
|
||
// newConvSessions builds the session map, restoring any persisted state from
|
||
// path. A missing or corrupt file degrades to an empty map (see
|
||
// loadConvSessionMap) — it never panics or blocks startup. An empty path means
|
||
// in-memory only.
|
||
func newConvSessions(path string) *convSessions {
|
||
return &convSessions{m: loadConvSessionMap(path), path: path}
|
||
}
|
||
|
||
// persistLocked writes the current map to disk. The caller must hold s.mu, so
|
||
// the snapshot marshalled here is race-free and the write is serialized with
|
||
// every other map mutation. It is best-effort (see saveConvSessionMap).
|
||
func (s *convSessions) persistLocked() {
|
||
saveConvSessionMap(s.path, s.m)
|
||
}
|
||
|
||
func convSessionKey(convID string) string {
|
||
key := strings.TrimSpace(convID)
|
||
if key == "" {
|
||
key = "_default"
|
||
}
|
||
return key
|
||
}
|
||
|
||
// id returns the stable agent session ID for one conversation, minting and
|
||
// persisting one on first sight. It is used by transports that carry the session
|
||
// id in a JSON field rather than argv flags.
|
||
func (s *convSessions) id(convID string) string {
|
||
key := convSessionKey(convID)
|
||
s.mu.Lock()
|
||
defer s.mu.Unlock()
|
||
if id, ok := s.m[key]; ok {
|
||
return id
|
||
}
|
||
id := uuid.NewString()
|
||
s.m[key] = id
|
||
s.persistLocked()
|
||
return id
|
||
}
|
||
|
||
// args returns the session argv fragment for one conversation, minting a new
|
||
// session on first sight. A newly minted mapping is persisted so a restart can
|
||
// resume it.
|
||
func (s *convSessions) args(convID string) []string {
|
||
key := convSessionKey(convID)
|
||
s.mu.Lock()
|
||
defer s.mu.Unlock()
|
||
if id, ok := s.m[key]; ok {
|
||
return []string{"--resume", id}
|
||
}
|
||
id := uuid.NewString()
|
||
s.m[key] = id
|
||
s.persistLocked()
|
||
return []string{"--session-id", id}
|
||
}
|
||
|
||
// reset forgets a conversation's session so the next message starts a fresh
|
||
// one (a new UUID — the old one may or may not exist on the agent side, and a
|
||
// fresh ID is safe either way). The removal is persisted so a restart does not
|
||
// resurrect the dropped session.
|
||
func (s *convSessions) reset(convID string) {
|
||
key := convSessionKey(convID)
|
||
s.mu.Lock()
|
||
defer s.mu.Unlock()
|
||
delete(s.m, key)
|
||
s.persistLocked()
|
||
}
|
||
|
||
// qoderworkIdentityRe matches a leading self-introduction emitted by qodercli
|
||
// ("我是 Qoder …" / "I am Qoder …") up to the end of that first sentence. Anchored
|
||
// to the start so legitimate mid-text "Qoder" mentions are left untouched.
|
||
var qoderworkIdentityRe = regexp.MustCompile(`^\s*(?:我是\s*Qoder|[Ii]['’]?\s*a?m\s+Qoder)[^。.!!\n]*[。.!!]?`)
|
||
|
||
// brandReply makes a channel's reply present as the host the user linked through.
|
||
// Only qoderwork needs it: qodercli's headless identity ("我是 Qoder …") is locked
|
||
// at the model layer and cannot be overridden via --append-system-prompt (verified
|
||
// against 8 prompt-layer techniques), so we rewrite the leading self-intro in the
|
||
// reply instead. codebuddy/claudecode honour --append-system-prompt and don't need
|
||
// this. Non-identity replies have no leading "我是 Qoder", so they pass through.
|
||
func brandReply(channel, reply string) string {
|
||
if channel != "qoderwork" {
|
||
return reply
|
||
}
|
||
return qoderworkIdentityRe.ReplaceAllString(reply, "我是 QoderWork 助手,钉钉群里的智能助手。")
|
||
}
|
||
|
||
func execErrorMessage(err error) string {
|
||
if err == nil {
|
||
return ""
|
||
}
|
||
msg := err.Error()
|
||
if ee, ok := err.(*exec.ExitError); ok && len(ee.Stderr) > 0 {
|
||
msg = strings.TrimSpace(string(ee.Stderr))
|
||
}
|
||
return msg
|
||
}
|
||
|
||
// agentReplyIsError reports whether an agent's stdout is a bare backend error
|
||
// rather than a real answer. Claude Code prints provider failures as
|
||
// "API Error: <status> ..." on stdout and may still exit 0, so the connector
|
||
// cannot rely on exit code / non-empty stdout alone. This is a heuristic on the
|
||
// leading marker; a legitimate reply never starts with "API Error:".
|
||
func agentReplyIsError(s string) bool {
|
||
return strings.HasPrefix(strings.TrimSpace(s), "API Error:")
|
||
}
|
||
|
||
// agentSessionMissingError recognizes stale addressable sessions. Agent CLIs
|
||
// differ in wording, but the operational meaning is the same: the connector's
|
||
// persisted session id points at a conversation the CLI can no longer resume.
|
||
func agentSessionMissingError(msg string) bool {
|
||
lower := strings.ToLower(strings.TrimSpace(msg))
|
||
return strings.Contains(lower, "no conversation found with session id") ||
|
||
strings.Contains(lower, "conversation not found") ||
|
||
strings.Contains(lower, "session not found")
|
||
}
|
||
|
||
// agentBackendErrorReply turns a bare backend error into a short, actionable
|
||
// Chinese message for the chat, instead of echoing the raw provider error
|
||
// (issue #14). It keeps only the first line of the raw error, truncated.
|
||
func agentBackendErrorReply(raw string) string {
|
||
first := strings.TrimSpace(raw)
|
||
if i := strings.IndexByte(first, byte('\n')); i >= 0 {
|
||
first = strings.TrimSpace(first[:i])
|
||
}
|
||
return fmt.Sprintf("AI 后端调用失败(原始错误:%s)。如果你用的是自定义模型供应商,请用 --agent-model <你供应商支持的模型名> 指定模型后重连。",
|
||
truncateRunes(first, 160))
|
||
}
|
||
|
||
// providerBaseURLInjected reports whether a custom Anthropic-compatible provider
|
||
// base URL is in effect for the claude subprocess: either injected via the
|
||
// user's Claude settings (env, see claudeUserSettingsEnv) or already present in
|
||
// the connector's own environment. When it is, the built-in haiku pin must be
|
||
// dropped so the provider's default model is used (it may not map haiku).
|
||
func providerBaseURLInjected(env []string) bool {
|
||
for _, e := range env {
|
||
if k, _, ok := strings.Cut(e, "="); ok && strings.TrimSpace(k) == "ANTHROPIC_BASE_URL" {
|
||
return true
|
||
}
|
||
}
|
||
if v, ok := os.LookupEnv("ANTHROPIC_BASE_URL"); ok && strings.TrimSpace(v) != "" {
|
||
return true
|
||
}
|
||
return false
|
||
}
|
||
|
||
// stripModelArg removes a flag and its value from argv (the inverse of the
|
||
// insert branch in applyModelArg). Used to drop the built-in --model haiku pin
|
||
// when a custom provider is in effect and the user did not pick a model, so the
|
||
// provider's default model applies. No-op when flag is absent.
|
||
func stripModelArg(argv []string, flag string) []string {
|
||
for i := 1; i+1 < len(argv); i++ {
|
||
if argv[i] == flag {
|
||
out := append([]string(nil), argv[:i]...)
|
||
return append(out, argv[i+2:]...)
|
||
}
|
||
}
|
||
return append([]string(nil), argv...)
|
||
}
|
||
|
||
// connectWorkDir returns a stable empty directory to run forwarded agent CLIs
|
||
// from, so they don't scan the connector's $HOME/working tree and slow down.
|
||
func connectWorkDir() string {
|
||
d := filepath.Join(os.TempDir(), "dws-connect-wd")
|
||
_ = os.MkdirAll(d, 0o755)
|
||
return d
|
||
}
|
||
|
||
// locateBinary finds an agent CLI without hardcoding a single path: first by
|
||
// name on PATH, then by matching the given app-bundle glob patterns (so it is
|
||
// CPU-arch and version agnostic — use `*` where the arch/version dir varies).
|
||
// Returns the resolved path and whether it was found.
|
||
func locateBinary(names []string, globs []string) (string, bool) {
|
||
for _, n := range names {
|
||
if p, err := exec.LookPath(n); err == nil {
|
||
return p, true
|
||
}
|
||
}
|
||
for _, g := range globs {
|
||
matches, _ := filepath.Glob(g)
|
||
for _, m := range matches {
|
||
if info, err := os.Stat(m); err == nil && !info.IsDir() {
|
||
return m, true
|
||
}
|
||
}
|
||
}
|
||
return "", false
|
||
}
|
||
|
||
// agentNotInstalled builds a clear "install the dependency first" error, surfaced
|
||
// at connect time (preflight) rather than failing mid-message.
|
||
func agentNotInstalled(channel, app, installHint string) error {
|
||
return apperrors.NewValidation(fmt.Sprintf(
|
||
"渠道 %q 需要 %s,但本机没找到。请先安装:%s(或用 DWS_AGENT_CMD 指定可执行命令)",
|
||
channel, app, installHint))
|
||
}
|
||
|
||
func homeDir() string {
|
||
if h, err := os.UserHomeDir(); err == nil {
|
||
return h
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// agentSpec describes how to run (and install) one channel's local agent CLI.
|
||
// Adding a mainstream agent is one entry in agentSpecs — no other code changes.
|
||
// The message text is always appended as the final argv element at runtime.
|
||
type agentSpec struct {
|
||
app string // human-readable name for messages
|
||
bins []string // PATH lookup names
|
||
globs []string // app-bundle fallback globs (use * for arch/version dir)
|
||
argvTail []string // args after the binary (headless mode); text appended last
|
||
envFn func() []string // extra env, e.g. reuse a desktop app's login; nil if none
|
||
install []string // auto-install command argv; nil if not auto-installed (GUI app / curl|bash)
|
||
hint string // install hint shown when not found / not auto-installable
|
||
// modelFlag is the CLI's model-selection flag, used by --agent-model to
|
||
// override (or inject) the model. Empty = model not overridable.
|
||
modelFlag string
|
||
// ccSessions marks CLIs with Claude-Code-style addressable sessions
|
||
// (--session-id <uuid> to create, --resume <id> to continue) — the
|
||
// contract per-conversation memory relies on.
|
||
ccSessions bool
|
||
// streamArgvTail, when set, replaces argvTail for incremental-output runs
|
||
// (the card streams content as the agent produces it). Empty = the
|
||
// channel only supports one-shot replies.
|
||
streamArgvTail []string
|
||
// streamParser names the stdout protocol of streamArgvTail runs:
|
||
// "cc" = Claude-Code stream-json (content_block_delta text deltas),
|
||
// "qoder" = qodercli stream-json (assistant/result message events).
|
||
streamParser string
|
||
}
|
||
|
||
// codebuddyEnv reuses the WorkBuddy login by pointing codebuddy at WorkBuddy's
|
||
// config dir (same account, no separate login).
|
||
func codebuddyEnv() []string {
|
||
return []string{"CODEBUDDY_CONFIG_DIR=" + envOr("CODEBUDDY_CONFIG_DIR", filepath.Join(homeDir(), ".workbuddy"))}
|
||
}
|
||
|
||
// claudeUserSettingsEnv re-exposes the `env` block of the user-level Claude
|
||
// Code settings ($CLAUDE_CONFIG_DIR/settings.json, default ~/.claude) as
|
||
// process environment entries. The claudecode channel runs claude with
|
||
// `--setting-sources project` to keep the bot persona neutral (no operator
|
||
// hooks/plugins), but that also drops user settings — and third-party model
|
||
// providers (cc-switch and the like) store their credentials there as env
|
||
// vars (ANTHROPIC_BASE_URL / ANTHROPIC_AUTH_TOKEN ...). Without them claude
|
||
// falls back to the official login and replies "Not logged in" (issue #10).
|
||
// Injecting the env block restores provider auth while user hooks stay out.
|
||
// Variables already present in the process environment are not overridden.
|
||
func claudeUserSettingsEnv() []string {
|
||
dir := strings.TrimSpace(os.Getenv("CLAUDE_CONFIG_DIR"))
|
||
if dir == "" {
|
||
home := homeDir()
|
||
if home == "" {
|
||
return nil
|
||
}
|
||
dir = filepath.Join(home, ".claude")
|
||
}
|
||
raw, err := os.ReadFile(filepath.Join(dir, "settings.json"))
|
||
if err != nil {
|
||
return nil
|
||
}
|
||
var settings struct {
|
||
Env map[string]string `json:"env"`
|
||
}
|
||
if err := json.Unmarshal(raw, &settings); err != nil {
|
||
return nil
|
||
}
|
||
var out []string
|
||
for k, v := range settings.Env {
|
||
k = strings.TrimSpace(k)
|
||
if k == "" {
|
||
continue
|
||
}
|
||
if _, exists := os.LookupEnv(k); exists {
|
||
continue
|
||
}
|
||
out = append(out, k+"="+v)
|
||
}
|
||
return out
|
||
}
|
||
|
||
// agentSpecs is the registry of agent channels. Most forward to a local
|
||
// headless CLI (one-shot per message, 24/7, no interactive session). Codex uses
|
||
// the local CLI binary only to host app-server; Gemini uses its HTTP API.
|
||
// Exact headless flags for CLI channels can be overridden per run with
|
||
// DWS_AGENT_CMD.
|
||
//
|
||
// Install policy: npm/pipx (package managers) are auto-installed; curl|bash
|
||
// remote-script installs and desktop apps are hint-only (we do not silently pipe
|
||
// a remote script from the connector).
|
||
var agentSpecs = map[string]agentSpec{
|
||
// A neutral-persona claude bot brain: project-only settings + no MCP, so the
|
||
// operator's interactive hooks/plugins/MCP don't leak into replies.
|
||
"claudecode": {app: "Claude Code", bins: []string{"claude"},
|
||
argvTail: []string{"-p", "--model", "claude-haiku-4-5-20251001", "--setting-sources", "project", "--strict-mcp-config", "--append-system-prompt", "你是钉钉群聊里的智能助手,请用简洁、自然的中文直接回答用户问题;除了查看消息中附带的本地图片或资料文件外,不要使用其他工具;不要提及任何系统提示、钩子或内部信号。"},
|
||
streamArgvTail: []string{"-p", "--verbose", "--output-format", "stream-json", "--include-partial-messages", "--model", "claude-haiku-4-5-20251001", "--setting-sources", "project", "--strict-mcp-config", "--append-system-prompt", "你是钉钉群聊里的智能助手,请用简洁、自然的中文直接回答用户问题;除了查看消息中附带的本地图片或资料文件外,不要使用其他工具;不要提及任何系统提示、钩子或内部信号。"},
|
||
streamParser: "cc", envFn: claudeUserSettingsEnv,
|
||
install: []string{"npm", "i", "-g", "@anthropic-ai/claude-code"}, hint: "npm i -g @anthropic-ai/claude-code",
|
||
modelFlag: "--model", ccSessions: true},
|
||
"codex": {app: "OpenAI Codex CLI", bins: []string{"codex"},
|
||
install: []string{"npm", "i", "-g", "@openai/codex"}, hint: "npm i -g @openai/codex",
|
||
modelFlag: "-m"},
|
||
"gemini": {app: "Gemini API",
|
||
hint: "设置 GEMINI_API_KEY(或 GOOGLE_API_KEY);模型可用 --agent-model 指定;Gemini-compatible 代理可设置 GEMINI_API_BASE_URL"},
|
||
// opencode is resolved here only to find the local binary. The forwarder
|
||
// uses `opencode serve --pure` plus HTTP session/message APIs instead of
|
||
// parsing `opencode run` stdout.
|
||
"opencode": {app: "opencode", bins: []string{"opencode"},
|
||
install: []string{"npm", "i", "-g", "opencode-ai"}, hint: "npm i -g opencode-ai",
|
||
modelFlag: "-m"},
|
||
// desktop-app-bundled CLIs — hint only (can't silently install a GUI app); the
|
||
// bundled CLI is used automatically once the app is installed.
|
||
// qodercli supports addressable sessions; DWS persists the conversation
|
||
// mapping like other --session-id/--resume CLIs so restarts can resume.
|
||
"qoder": {app: "Qoder", bins: []string{"qodercli"},
|
||
globs: []string{"/Applications/Qoder.app/Contents/Resources/app/resources/bin/*/qodercli"},
|
||
argvTail: []string{"-o", "text", "--max-turns", "30", "-p"},
|
||
streamArgvTail: []string{"-o", "stream-json", "--max-turns", "30", "-p"},
|
||
streamParser: "qoder", hint: "https://qoder.com",
|
||
modelFlag: "--model", ccSessions: true},
|
||
"qoderwork": {app: "QoderWork", bins: []string{"qodercli"},
|
||
globs: []string{"/Applications/QoderWork.app/Contents/Resources/bin/qodercli"},
|
||
argvTail: []string{"-o", "text", "--max-turns", "30", "-p"},
|
||
streamArgvTail: []string{"-o", "stream-json", "--max-turns", "30", "-p"},
|
||
streamParser: "qoder", hint: "https://qoder.com",
|
||
modelFlag: "--model", ccSessions: true},
|
||
"codebuddy": {app: "WorkBuddy(自带 codebuddy)", bins: []string{"codebuddy"},
|
||
globs: []string{"/Applications/WorkBuddy.app/Contents/Resources/app.asar.unpacked/cli/bin/codebuddy"},
|
||
argvTail: []string{"-p"},
|
||
streamArgvTail: []string{"-p", "--output-format", "stream-json", "--include-partial-messages"},
|
||
streamParser: "cc", envFn: codebuddyEnv, hint: "https://www.codebuddy.cn/work/",
|
||
modelFlag: "--model", ccSessions: true},
|
||
// workbuddy reuses codebuddy's binary but is reached through the WorkBuddy
|
||
// host, so inject a WorkBuddy persona — otherwise the bot self-identifies as
|
||
// "CodeBuddy Code" (codebuddy's built-in identity), which confuses users who
|
||
// linked via WorkBuddy. --append-system-prompt is enough; a full override
|
||
// would drop codebuddy's agent scaffolding.
|
||
// The persona prompt also forbids tool use: in headless mode codebuddy
|
||
// otherwise tries tools (e.g. writing a memory file), stalls on the
|
||
// permission gate and replies with a permissions lecture — or nothing.
|
||
"workbuddy": {app: "WorkBuddy(自带 codebuddy)", bins: []string{"codebuddy"},
|
||
globs: []string{"/Applications/WorkBuddy.app/Contents/Resources/app.asar.unpacked/cli/bin/codebuddy"},
|
||
argvTail: []string{"--append-system-prompt",
|
||
"你叫「WorkBuddy 助手」,是钉钉群里的智能助手。无论被问到你是谁,都只能自称 WorkBuddy 助手,绝不能提到 CodeBuddy 这个名字。请用简洁自然的中文直接回答问题;不要使用任何工具,不要尝试读写文件或执行命令。",
|
||
"-p"},
|
||
streamArgvTail: []string{"--append-system-prompt",
|
||
"你叫「WorkBuddy 助手」,是钉钉群里的智能助手。无论被问到你是谁,都只能自称 WorkBuddy 助手,绝不能提到 CodeBuddy 这个名字。请用简洁自然的中文直接回答问题;不要使用任何工具,不要尝试读写文件或执行命令。",
|
||
"-p", "--output-format", "stream-json", "--include-partial-messages"},
|
||
streamParser: "cc", envFn: codebuddyEnv, hint: "https://www.codebuddy.cn/work/",
|
||
modelFlag: "--model", ccSessions: true},
|
||
// custom: an escape hatch for self-built / not-yet-supported agent CLIs
|
||
// (issue #37, e.g. 网易有道龙虾 LobsterAI). It has NO built-in binary — the
|
||
// full command comes from --agent-cmd (which sets DWS_AGENT_CMD) or
|
||
// DWS_AGENT_CMD directly. dws starts the Stream and forwards each @-bot
|
||
// message to that command with the question appended as the trailing
|
||
// argument, using stdout as the reply. One-shot only (no streaming/session/
|
||
// model override, since the CLI's protocol is unknown), so any headless AI
|
||
// tool can be onboarded without code changes.
|
||
"custom": {app: "自定义 AI 工具(--agent-cmd / DWS_AGENT_CMD)", bins: nil,
|
||
hint: "用 --agent-cmd \"<可执行命令>\" 指定你的 AI 工具(无头/一次性模式:问题作为最后一个参数追加,答案打到 stdout),或设环境变量 DWS_AGENT_CMD"},
|
||
}
|
||
|
||
// autoInstallEnabled reports whether dws may auto-run a package-manager install
|
||
// for a missing agent. Default on; opt out with DWS_CONNECT_NO_INSTALL=1.
|
||
func autoInstallEnabled() bool {
|
||
return strings.TrimSpace(os.Getenv("DWS_CONNECT_NO_INSTALL")) == ""
|
||
}
|
||
|
||
// runAgentInstall runs a spec's install command (package-manager only), streaming
|
||
// output to stderr, bounded by a timeout.
|
||
func runAgentInstall(channel string, spec agentSpec) error {
|
||
fmt.Fprintf(os.Stderr, "[connect] 渠道 %s 的 %s 未安装,自动安装中: %s\n", channel, spec.app, strings.Join(spec.install, " "))
|
||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
|
||
defer cancel()
|
||
cmd := exec.CommandContext(ctx, spec.install[0], spec.install[1:]...)
|
||
cmd.Stdout = os.Stderr
|
||
cmd.Stderr = os.Stderr
|
||
if err := cmd.Run(); err != nil {
|
||
fmt.Fprintf(os.Stderr, "[connect] 自动安装失败: %v\n", err)
|
||
return err
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// connectCliStatus reports whether a channel's local CLI dependency is
|
||
// present, without installing anything — the machine-readable preflight that
|
||
// lets an agent check (via --dry-run) and guide the user BEFORE starting the
|
||
// connector. External channels report their own onboarding tool.
|
||
func connectCliStatus(channel string) map[string]any {
|
||
switch channel {
|
||
case "openclaw", "hermes":
|
||
bin := channel
|
||
_, found := locateBinary([]string{bin}, nil)
|
||
return map[string]any{
|
||
"required": bin, "installed": found,
|
||
"autoInstall": false,
|
||
"installHint": "渠道 " + channel + " 走官方建联,请先安装并完成其 onboarding",
|
||
}
|
||
case "custom":
|
||
// custom has no built-in binary; "installed" means a command was supplied
|
||
// via --agent-cmd / DWS_AGENT_CMD.
|
||
command := strings.TrimSpace(os.Getenv("DWS_AGENT_CMD"))
|
||
status := map[string]any{
|
||
"required": "自定义命令(--agent-cmd / DWS_AGENT_CMD)",
|
||
"installed": command != "",
|
||
"autoInstall": false,
|
||
"installHint": "用 --agent-cmd \"<可执行命令>\" 指定你的 AI 工具",
|
||
}
|
||
if command != "" {
|
||
status["command"] = command
|
||
}
|
||
return status
|
||
case "gemini":
|
||
status := map[string]any{
|
||
"required": "GEMINI_API_KEY or GOOGLE_API_KEY",
|
||
"installed": geminiAPIKey() != "",
|
||
"autoInstall": false,
|
||
"installHint": "设置 GEMINI_API_KEY(或 GOOGLE_API_KEY);模型可用 --agent-model 指定;Gemini-compatible 代理可设置 GEMINI_API_BASE_URL",
|
||
}
|
||
if base := strings.TrimSpace(geminiAPIBaseURL()); base != "" {
|
||
status["baseURL"] = base
|
||
}
|
||
return status
|
||
}
|
||
spec, ok := agentSpecs[channel]
|
||
if !ok {
|
||
return map[string]any{"required": "", "installed": false}
|
||
}
|
||
path, found := locateBinary(spec.bins, spec.globs)
|
||
status := map[string]any{
|
||
"required": spec.app,
|
||
"installed": found,
|
||
"autoInstall": len(spec.install) > 0 && autoInstallEnabled(),
|
||
"installHint": spec.hint,
|
||
}
|
||
if found {
|
||
status["path"] = path
|
||
}
|
||
return status
|
||
}
|
||
|
||
// resolveExecAgent resolves a channel's agent CLI to a runnable argv (+ env).
|
||
// Order for non-Codex channels: DWS_AGENT_CMD override > binary on
|
||
// PATH/app-bundle > auto-install (pkg managers) > install-guidance error. Codex
|
||
// ignores DWS_AGENT_CMD because its channel is app-server only. Preflighted at
|
||
// connect time, not on the first DingTalk message.
|
||
func resolveExecAgent(channel string) (argv []string, env []string, err error) {
|
||
if v := strings.TrimSpace(os.Getenv("DWS_AGENT_CMD")); v != "" && channel != "codex" {
|
||
return strings.Fields(v), nil, nil
|
||
}
|
||
if channel == "custom" {
|
||
// custom has no built-in binary: its argv MUST come from --agent-cmd /
|
||
// DWS_AGENT_CMD (handled above). Reaching here means neither was set.
|
||
return nil, nil, apperrors.NewValidation("custom 渠道需要用 --agent-cmd \"<可执行命令>\"(或环境变量 DWS_AGENT_CMD)指定你的 AI 工具:无头/一次性模式,用户问题作为最后一个参数追加,回答打到 stdout")
|
||
}
|
||
spec, ok := agentSpecs[channel]
|
||
if !ok {
|
||
return nil, nil, apperrors.NewValidation(fmt.Sprintf("渠道 %q 不是 exec 型渠道", channel))
|
||
}
|
||
bin, found := locateBinary(spec.bins, spec.globs)
|
||
if !found && len(spec.install) > 0 && autoInstallEnabled() {
|
||
if ierr := runAgentInstall(channel, spec); ierr == nil {
|
||
bin, found = locateBinary(spec.bins, spec.globs)
|
||
}
|
||
}
|
||
if !found {
|
||
return nil, nil, agentNotInstalled(channel, spec.app, spec.hint)
|
||
}
|
||
argv = append([]string{bin}, spec.argvTail...)
|
||
if spec.envFn != nil {
|
||
env = spec.envFn()
|
||
}
|
||
return argv, env, nil
|
||
}
|
||
|
||
// forwarderForChannel builds the forwarder for a channel. Every stream-bridge
|
||
// channel forwards to its corresponding LOCAL CLI product (one-shot, runs 24/7),
|
||
// resolved via PATH → app bundle → install guidance — no hardcoded path, no
|
||
// dependency on a live interactive session. opts applies the user-facing agent
|
||
// tuning (--agent-model / --agent-workdir / --agent-memory).
|
||
func forwarderForChannel(channel, clientID string, opts connectAgentOptions) (forwarder, error) {
|
||
timeout := opts.Timeout
|
||
if timeout <= 0 {
|
||
timeout = envDurationMS("DWS_AGENT_TIMEOUT_MS", 0)
|
||
}
|
||
spec, ok := agentSpecs[channel]
|
||
if !ok {
|
||
return nil, apperrors.NewValidation(fmt.Sprintf("渠道 %q 不是 stream-bridge 渠道,无 forwarder", channel))
|
||
}
|
||
overridden := strings.TrimSpace(os.Getenv("DWS_AGENT_CMD")) != "" && channel != "codex"
|
||
if channel == "gemini" && !overridden {
|
||
return newGeminiAPIForwarder(timeout, opts)
|
||
}
|
||
// Resolve the agent CLI (PATH → app bundle → auto-install → guidance) and
|
||
// preflight here so a missing dependency errors at connect time.
|
||
argv, env, err := resolveExecAgent(channel)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
// A DWS_AGENT_CMD override is a fully user-controlled argv: do not splice in
|
||
// model or session flags we cannot know are valid for it. Codex ignores the
|
||
// override because its channel is app-server only; custom commands belong on
|
||
// --channel custom.
|
||
userPickedModel := opts.Model != "" || strings.TrimSpace(os.Getenv("DWS_AGENT_MODEL")) != ""
|
||
if !overridden && opts.Model != "" {
|
||
if spec.modelFlag == "" {
|
||
return nil, apperrors.NewValidation(fmt.Sprintf("渠道 %q 的 agent CLI 不支持模型覆盖(--agent-model)", channel))
|
||
}
|
||
argv = applyModelArg(argv, spec.modelFlag, opts.Model)
|
||
}
|
||
// Custom-provider default-model fix (issue #14): when a third-party
|
||
// Anthropic-compatible provider is in effect (ANTHROPIC_BASE_URL injected via
|
||
// the user's Claude settings or already in our env) and the user did not pick
|
||
// a model, drop the spec's built-in model pin (claudecode's haiku) so the
|
||
// provider's default model applies — the provider may not map that exact pin
|
||
// and would otherwise return an error (a 422 that got echoed into the chat).
|
||
// Official-login users (no base URL) keep the built-in pin unchanged.
|
||
dropBuiltinModel := !overridden && !userPickedModel && spec.modelFlag != "" && providerBaseURLInjected(env)
|
||
if dropBuiltinModel {
|
||
argv = stripModelArg(argv, spec.modelFlag)
|
||
}
|
||
var sessions *convSessions
|
||
if !overridden && opts.Memory && spec.ccSessions {
|
||
// Scope the on-disk session store by clientId so multiple bots on one
|
||
// machine stay isolated; an empty clientId disables persistence and the
|
||
// map stays in memory.
|
||
sessionStore := connectSessionStorePath(clientID)
|
||
sessions = newConvSessions(sessionStore)
|
||
}
|
||
// Incremental-output argv: same binary, the spec's stream tail, same model
|
||
// override. A DWS_AGENT_CMD override disables streaming (unknown argv).
|
||
var streamArgv []string
|
||
parser := ""
|
||
if !overridden && len(spec.streamArgvTail) > 0 {
|
||
streamArgv = append([]string{argv[0]}, spec.streamArgvTail...)
|
||
if opts.Model != "" && spec.modelFlag != "" {
|
||
streamArgv = applyModelArg(streamArgv, spec.modelFlag, opts.Model)
|
||
} else if dropBuiltinModel {
|
||
streamArgv = stripModelArg(streamArgv, spec.modelFlag)
|
||
}
|
||
parser = spec.streamParser
|
||
}
|
||
if channel == "codex" {
|
||
return newCodexAppServerForwarder(argv[0], env, timeout, opts, clientID), nil
|
||
}
|
||
// opencode uses its official local HTTP server; DWS keeps the
|
||
// conversation→session mapping and sends one-shot group replies.
|
||
if channel == "opencode" && !overridden {
|
||
return newOpencodeForwarder(argv[0], env, timeout, opts, clientID), nil
|
||
}
|
||
if (channel == "qoder" || channel == "qoderwork") && !overridden {
|
||
return newQoderStreamForwarder(channel, argv[0], env, timeout, opts, sessions), nil
|
||
}
|
||
// Yolo: splice per-agent permission flags for exec-based forwarders.
|
||
if !overridden && opts.Yolo {
|
||
switch channel {
|
||
case "claudecode", "codebuddy", "workbuddy":
|
||
argv = append(argv, "--permission-mode", "bypassPermissions", "--dangerously-skip-permissions")
|
||
if len(streamArgv) > 0 {
|
||
streamArgv = append(streamArgv, "--permission-mode", "bypassPermissions", "--dangerously-skip-permissions")
|
||
}
|
||
}
|
||
}
|
||
base := &execForwarder{name: channel, argv: argv, env: env, timeout: timeout,
|
||
workDir: opts.WorkDir, sessions: sessions,
|
||
streamArgv: streamArgv, parser: parser}
|
||
return base, nil
|
||
}
|
||
|
||
// applyModelArg returns argv with the model flag set to model: if flag is
|
||
// already present its value is replaced (e.g. claudecode's built-in haiku
|
||
// pin), otherwise flag+model are inserted right after the binary — before the
|
||
// spec tail, because some tails end with `-p` and the prompt must stay the
|
||
// trailing positional argument.
|
||
func applyModelArg(argv []string, flag, model string) []string {
|
||
out := append([]string(nil), argv...)
|
||
for i := 1; i < len(out)-1; i++ {
|
||
if out[i] == flag {
|
||
out[i+1] = model
|
||
return out
|
||
}
|
||
}
|
||
return append(out[:1:1], append([]string{flag, model}, out[1:]...)...)
|
||
}
|
||
|
||
func insertBeforeArg(argv []string, marker string, values ...string) []string {
|
||
for i := 1; i < len(argv); i++ {
|
||
if argv[i] == marker {
|
||
out := append([]string(nil), argv[:i]...)
|
||
out = append(out, values...)
|
||
return append(out, argv[i:]...)
|
||
}
|
||
}
|
||
out := append([]string(nil), argv...)
|
||
return append(out, values...)
|
||
}
|
||
|
||
// msgDedup tracks recently-seen MsgIds so a redelivered message is not
|
||
// processed (and replied to) twice. Memory is bounded: once the set reaches
|
||
// limit it is cleared (the chance of a very old MsgId being redelivered after a
|
||
// reset is negligible).
|
||
type msgDedup struct {
|
||
mu sync.Mutex
|
||
seen map[string]struct{}
|
||
limit int
|
||
}
|
||
|
||
func newMsgDedup(limit int) *msgDedup {
|
||
return &msgDedup{seen: make(map[string]struct{}), limit: limit}
|
||
}
|
||
|
||
// first reports whether id is seen for the first time (true) or is a duplicate
|
||
// (false).
|
||
func (d *msgDedup) first(id string) bool {
|
||
d.mu.Lock()
|
||
defer d.mu.Unlock()
|
||
if _, dup := d.seen[id]; dup {
|
||
return false
|
||
}
|
||
if len(d.seen) >= d.limit {
|
||
d.seen = make(map[string]struct{})
|
||
}
|
||
d.seen[id] = struct{}{}
|
||
return true
|
||
}
|
||
|
||
// runStreamConnector opens a Go-native DingTalk Stream (in-process, no node /
|
||
// external script), subscribes to the chatbot callback: on an @-bot message it
|
||
// feeds the text to the forwarder and sends the reply back via sessionWebhook.
|
||
// Runs in the foreground, blocking until ctx is cancelled (Ctrl-C).
|
||
//
|
||
// The callback acks immediately (returns right away) and does the potentially
|
||
// slow forward + reply in a goroutine. The SDK only acks after the callback
|
||
// returns (client.processDataFrame), so a slow callback delays the ack and
|
||
// DingTalk redelivers the un-acked message — producing duplicate replies. A
|
||
// forward can easily exceed DingTalk's ack window (claude -p, qodercli, or the
|
||
// workbuddy bridge's wait), so ack-first is mandatory, not optional. Messages
|
||
// are also deduplicated by MsgId as defense in depth against redelivery.
|
||
// connectExtras carries the optional Q&A hardening features into the stream
|
||
// connector; nil/zero fields are off.
|
||
type connectExtras struct {
|
||
gate *connectGate
|
||
kb *knowledgeBase
|
||
approval *approvalOrchestrator
|
||
// persona is a role's system-prompt fragment prepended to every forwarded
|
||
// prompt (empty = no prefix). Sourced from RoleConfig.Persona.
|
||
persona string
|
||
}
|
||
|
||
type connectQueuedTurn struct {
|
||
convID string
|
||
text string
|
||
picCodes []string
|
||
fileInfo fileInboundInfo
|
||
webhook string
|
||
msgID string
|
||
msgType string
|
||
senderStaffID string
|
||
conversationID string
|
||
conversationType string
|
||
callbackData chatbot.BotCallbackDataModel
|
||
}
|
||
|
||
func mergeConnectQueuedTurns(turns []connectQueuedTurn) connectQueuedTurn {
|
||
if len(turns) == 0 {
|
||
return connectQueuedTurn{}
|
||
}
|
||
if len(turns) == 1 {
|
||
return turns[0]
|
||
}
|
||
for i := len(turns) - 1; i >= 0; i-- {
|
||
if connectTurnShouldStayStandalone(turns[i]) {
|
||
return turns[i]
|
||
}
|
||
}
|
||
merged := turns[len(turns)-1]
|
||
lines := make([]string, 0, len(turns)+1)
|
||
lines = append(lines, "用户在上一轮处理期间连续发送了以下消息,请把它们作为同一个最新请求一起处理:")
|
||
for i, turn := range turns {
|
||
lines = append(lines, fmt.Sprintf("%d. %s", i+1, connectTurnSummary(turn)))
|
||
}
|
||
merged.text = strings.Join(lines, "\n")
|
||
merged.picCodes = nil
|
||
for i := range turns {
|
||
merged.picCodes = append(merged.picCodes, turns[i].picCodes...)
|
||
}
|
||
if !merged.fileInfo.hasActionable() {
|
||
for i := len(turns) - 1; i >= 0; i-- {
|
||
if turns[i].fileInfo.hasActionable() {
|
||
merged.fileInfo = turns[i].fileInfo
|
||
break
|
||
}
|
||
}
|
||
}
|
||
return merged
|
||
}
|
||
|
||
func connectTurnShouldStayStandalone(turn connectQueuedTurn) bool {
|
||
if _, ok := parseConnectControlCommand(turn.text); ok {
|
||
return true
|
||
}
|
||
if _, ok := parseDecisionWord(turn.text); ok {
|
||
return true
|
||
}
|
||
return isRetryWord(turn.text)
|
||
}
|
||
|
||
func connectTurnSummary(turn connectQueuedTurn) string {
|
||
if text := strings.TrimSpace(turn.text); text != "" {
|
||
if len(turn.picCodes) > 0 {
|
||
return text + " [同时附有图片]"
|
||
}
|
||
return text
|
||
}
|
||
if len(turn.picCodes) > 0 {
|
||
return "[图片]"
|
||
}
|
||
if turn.fileInfo.hasActionable() {
|
||
if name := strings.TrimSpace(turn.fileInfo.FileName); name != "" {
|
||
return "[文件: " + name + "]"
|
||
}
|
||
return "[文件]"
|
||
}
|
||
return "[空消息]"
|
||
}
|
||
|
||
func runStreamConnector(ctx context.Context, channel, clientID, clientSecret string, fwd forwarder, cardCli *aiCardClient, extras *connectExtras) error {
|
||
if extras == nil {
|
||
extras = &connectExtras{}
|
||
}
|
||
checkFDLimit()
|
||
if closer, ok := fwd.(forwarderCloser); ok {
|
||
defer func() {
|
||
if err := closer.close(); err != nil {
|
||
fmt.Fprintf(os.Stderr, "[connect] 关闭本地 agent 失败(忽略): %v\n", err)
|
||
}
|
||
}()
|
||
}
|
||
// One connector per robot per machine — duplicate Stream connections on
|
||
// one clientId get messages load-balanced between them (bot answers
|
||
// intermittently), see acquireConnectLock.
|
||
release, err := acquireConnectLock(clientID)
|
||
if err != nil {
|
||
return apperrors.NewValidation(err.Error())
|
||
}
|
||
defer release()
|
||
|
||
// Health heartbeat: record connect/receive/reply/error so `connect status`
|
||
// can tell a live connector from a dead one (see connect_health.go). Nil
|
||
// when no clientId identity is available; all calls below are no-ops then.
|
||
health := newConnectHealth(clientID, channel)
|
||
health.start(ctx)
|
||
|
||
streamLoggerOnce.Do(func() { sdklogger.SetLogger(streamSDKLogger{}) })
|
||
replier := chatbot.NewChatbotReplier()
|
||
dedup := newMsgDedup(10000)
|
||
queue := newConvQueue()
|
||
// Media downloads need an authenticated API client even when cards are
|
||
// off (aiCardClient with an empty template is creds+HTTP only).
|
||
mediaCli := cardCli
|
||
if mediaCli == nil {
|
||
mediaCli = newAICardClient(clientID, clientSecret, "")
|
||
}
|
||
|
||
keepAlive := envDurationMS("DWS_CONNECT_KEEPALIVE_MS", 30*time.Second)
|
||
fmt.Fprintf(os.Stderr, "[connect] keepAlive=%s autoReconnect=true\n", keepAlive)
|
||
cli := client.NewStreamClient(
|
||
client.WithAppCredential(client.NewAppCredentialConfig(clientID, clientSecret)),
|
||
client.WithAutoReconnect(true),
|
||
client.WithKeepAlive(keepAlive),
|
||
)
|
||
cli.RegisterChatBotCallbackRouter(func(_ context.Context, data *chatbot.BotCallbackDataModel) ([]byte, error) {
|
||
text := strings.TrimSpace(data.Text.Content)
|
||
msgtype := strings.TrimSpace(data.Msgtype)
|
||
// Picture messages carry no text — their payload is a downloadCode
|
||
// resolved to a local file in the forward goroutine below.
|
||
var picCodes []string
|
||
if strings.EqualFold(msgtype, "picture") {
|
||
if code := pictureDownloadCode(data.Content); code != "" {
|
||
picCodes = append(picCodes, code)
|
||
}
|
||
}
|
||
// A richText callback can mix text and inline picture nodes. Scan its
|
||
// content even when data.Text.Content is already populated; otherwise
|
||
// the text survives while every embedded picture silently disappears.
|
||
if strings.EqualFold(msgtype, "richText") {
|
||
picCodes = append(picCodes, richTextPictureDownloadCodes(data.Content)...)
|
||
}
|
||
// File callbacks come in two shapes: client-sent files carry a
|
||
// downloadCode; API-sent files (`dws chat message send --msg-type file
|
||
// --dentry-id --space-id`) carry dentryId + spaceId instead and have
|
||
// NO downloadCode. Both have to be recognisable or legit file messages
|
||
// get silently dropped below.
|
||
var fileInfo fileInboundInfo
|
||
if strings.EqualFold(msgtype, "file") {
|
||
fileInfo = parseFileInbound(data.Content)
|
||
}
|
||
// Structured-text fallback: DingTalk leaves data.Text.Content blank on
|
||
// markdown / richText callbacks (the body ships in data.Content). Without
|
||
// this, `dws chat message send --group ... --text ...` — which defaults
|
||
// to msgType=markdown — hits the drop branch below and the bot looks
|
||
// dead to the sender.
|
||
if text == "" && len(picCodes) == 0 {
|
||
// interactiveCard (a bot @-mentioning this bot) nests the body in
|
||
// content.cardContent and carries the mention as its own leading
|
||
// leaf; the leaf-aware extractor drops it so the agent gets the
|
||
// clean instruction. Other structured-text shapes use the generic
|
||
// extractor.
|
||
if strings.EqualFold(msgtype, "interactiveCard") {
|
||
text = extractInteractiveCardText(data.Content)
|
||
}
|
||
if text == "" {
|
||
if fallback := extractCallbackText(data.Content); fallback != "" {
|
||
text = fallback
|
||
}
|
||
}
|
||
}
|
||
if (text == "" && len(picCodes) == 0 && !fileInfo.hasActionable()) || data.SessionWebhook == "" {
|
||
// Observability: silent drops are the #1 reason a working connector
|
||
// looks dead. Log msgtype + a payload summary so an unhandled shape
|
||
// (e.g. new-style file callback without downloadCode) shows up in
|
||
// stderr instead of being invisible.
|
||
fmt.Fprintf(os.Stderr, "[connect] 丢弃消息 msgtype=%q staffId=%s convId=%s msgId=%s content=%s (无正文/图片/可下载文件或 sessionWebhook 为空)\n",
|
||
msgtype, data.SenderStaffId, data.ConversationId, data.MsgId, summarizeContent(data.Content))
|
||
return []byte(""), nil
|
||
}
|
||
// Drop redelivered duplicates so a retried message is not replied twice.
|
||
if id := strings.TrimSpace(data.MsgId); id != "" && !dedup.first(id) {
|
||
return []byte(""), nil
|
||
}
|
||
// Access policy (--allowed-users / --allowed-groups /
|
||
// --user-rate-limit): denied messages are dropped with a log line —
|
||
// replying to them would itself be a spend amplifier.
|
||
if extras.gate.enabled() {
|
||
if ok, reason := extras.gate.allow(
|
||
strings.TrimSpace(data.SenderStaffId),
|
||
strings.TrimSpace(data.ConversationType),
|
||
strings.TrimSpace(data.ConversationId),
|
||
); !ok {
|
||
fmt.Fprintf(os.Stderr, "[connect] 已拦截消息(%s): staffId=%s convId=%s\n",
|
||
reason, data.SenderStaffId, data.ConversationId)
|
||
return []byte(""), nil
|
||
}
|
||
}
|
||
// Observability: without a receive log the operator cannot tell a working
|
||
// silent connector apart from a dead one ("有没有收到?"). Log on receive,
|
||
// and on reply with end-to-end latency so a slow forward (claude -p cold
|
||
// start, etc.) is visible rather than guessed at.
|
||
sender := strings.TrimSpace(data.SenderNick)
|
||
if sender == "" {
|
||
sender = strings.TrimSpace(data.SenderStaffId)
|
||
}
|
||
shown := text
|
||
if shown == "" && len(picCodes) > 0 {
|
||
shown = "[图片]"
|
||
} else if shown == "" && fileInfo.hasActionable() {
|
||
shown = "[文件: " + fileInfo.FileName + "]"
|
||
}
|
||
fmt.Fprintf(os.Stderr, "[connect] 收到 @%s: %s (convType=%s convId=%s staffId=%s msgId=%s)\n",
|
||
sender, truncateRunes(shown, 80), data.ConversationType, data.ConversationId, data.SenderStaffId, data.MsgId)
|
||
health.onPush()
|
||
// Ack-first: return now, reply asynchronously via sessionWebhook (which is
|
||
// independent of the Stream ack). Use a background context so the in-flight
|
||
// forward is not cancelled by the SDK when this callback returns.
|
||
webhook := data.SessionWebhook
|
||
// Conversation key for session memory: the DingTalk conversation ID, so a
|
||
// group chat shares one agent session and a 1:1 chat gets its own.
|
||
convID := strings.TrimSpace(data.ConversationId)
|
||
if convID == "" {
|
||
convID = strings.TrimSpace(data.SenderStaffId)
|
||
}
|
||
msgID := strings.TrimSpace(data.MsgId)
|
||
turn := connectQueuedTurn{
|
||
convID: convID,
|
||
text: text,
|
||
picCodes: picCodes,
|
||
fileInfo: fileInfo,
|
||
webhook: webhook,
|
||
msgID: msgID,
|
||
msgType: msgtype,
|
||
senderStaffID: strings.TrimSpace(data.SenderStaffId),
|
||
conversationID: strings.TrimSpace(data.ConversationId),
|
||
conversationType: strings.TrimSpace(data.ConversationType),
|
||
callbackData: *data,
|
||
}
|
||
// Same-conversation agent calls never run in parallel; messages received
|
||
// while a turn is running are merged into one pending follow-up instead
|
||
// of forming an unbounded stale FIFO.
|
||
queue.submit(turn, func(turns []connectQueuedTurn) {
|
||
turn := mergeConnectQueuedTurns(turns)
|
||
if len(turns) > 1 {
|
||
fmt.Fprintf(os.Stderr, "[connect] 合并 %d 条待处理消息 (convId=%s, latestMsgId=%s)\n", len(turns), turn.convID, turn.msgID)
|
||
}
|
||
text := turn.text
|
||
picCodes := turn.picCodes
|
||
fileInfo := turn.fileInfo
|
||
webhook := turn.webhook
|
||
convID := turn.convID
|
||
msgID := turn.msgID
|
||
msgtype := turn.msgType
|
||
callbackData := &turn.callbackData
|
||
// Digital-twin text approval: if this is the OWNER replying
|
||
// 「同意」/「拒绝」 (in their 1:1 chat with the bot) to the pending
|
||
// request, route it to the gate (decide → execute/decline, with
|
||
// private owner ack + requester outcome) instead of forwarding it to
|
||
// the agent. Returns true only when it consumed the message.
|
||
if extras.approval.handleOwnerDecision(context.Background(),
|
||
strings.TrimSpace(callbackData.SenderStaffId), text) {
|
||
return
|
||
}
|
||
// Built-in slash commands (/new, /clear): reset this conversation's
|
||
// session and ack, instead of forwarding to the agent. Matches only
|
||
// when the whole message is the command (parseConnectControlCommand),
|
||
// so a normal question is unaffected. Costs no agent turn / tokens.
|
||
if action, isCmd := parseConnectControlCommand(text); isCmd {
|
||
ackText := action.ack
|
||
if action.resetsSession() {
|
||
cleared := false
|
||
// /clear prefers a real server-side session delete when the
|
||
// channel's agent supports one (opencode). /new always just
|
||
// drops the local mapping so the old session stays resumable.
|
||
if action.name == "clear" {
|
||
if c, canClear := fwd.(sessionClearer); canClear {
|
||
cctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||
if err := c.clearSession(cctx, convID); err != nil {
|
||
fmt.Fprintf(os.Stderr, "[connect] /clear 删除会话失败 (%s, msgId=%s): %v\n", channel, msgID, err)
|
||
}
|
||
cancel()
|
||
cleared = true
|
||
}
|
||
}
|
||
if !cleared {
|
||
if r, canReset := fwd.(sessionResetter); canReset {
|
||
r.resetSession(convID)
|
||
} else {
|
||
ackText = "当前渠道暂不支持会话指令(/new、/clear)。"
|
||
}
|
||
}
|
||
}
|
||
if serr := replier.SimpleReplyText(context.Background(), webhook, []byte(ackText)); serr != nil {
|
||
fmt.Fprintf(os.Stderr, "[connect] 指令回复发送失败 (%s, msgId=%s): %v\n", channel, msgID, serr)
|
||
}
|
||
return
|
||
}
|
||
started := time.Now()
|
||
// Assemble the forwarded prompt: resolve an attached picture (the
|
||
// top Q&A inbound is an error screenshot), then knowledge-augment.
|
||
prompt := text
|
||
for i, picCode := range picCodes {
|
||
localPath, derr := mediaCli.downloadMessageFile(context.Background(), clientID, picCode)
|
||
if derr != nil {
|
||
fmt.Fprintf(os.Stderr, "[connect][media] 第 %d 张图片下载失败: %v\n", i+1, derr)
|
||
if prompt == "" {
|
||
prompt = "(用户发来图片,但图片下载失败了。请告知用户图片没收到,建议补充文字描述。)"
|
||
} else {
|
||
prompt += "\n(用户同时附了一张图片,但图片下载失败,请基于现有文字回答并说明未能读取图片。)"
|
||
}
|
||
continue
|
||
}
|
||
if prompt == "" {
|
||
prompt = "用户发来一张图片(本地路径 " + localPath + "),请查看图片内容并回答其中的问题。"
|
||
} else {
|
||
prompt += "\n(用户同时附了一张图片,本地路径 " + localPath + ",请结合图片内容回答。)"
|
||
}
|
||
}
|
||
if fileInfo.hasActionable() {
|
||
fileName := fileInfo.FileName
|
||
var localPath string
|
||
var derr error
|
||
if fileInfo.DownloadCode != "" {
|
||
localPath, derr = mediaCli.downloadMessageFile(context.Background(), clientID, fileInfo.DownloadCode)
|
||
if derr != nil {
|
||
fmt.Fprintf(os.Stderr, "[connect][media] 文件下载失败: %v\n", derr)
|
||
}
|
||
}
|
||
switch {
|
||
case localPath != "":
|
||
if prompt == "" {
|
||
prompt = "用户发来一个文件「" + fileName + "」(本地路径 " + localPath + "),请读取文件内容并回答。"
|
||
} else {
|
||
prompt = prompt + "\n(用户同时附了一个文件「" + fileName + "」,本地路径 " + localPath + ",请结合文件内容回答。)"
|
||
}
|
||
case fileInfo.DentryID != 0 && fileInfo.SpaceID != 0:
|
||
// API-sent file: resolve via storage API (userId→unionId,
|
||
// then getDownloadInfo). Falls back to metadata-only prompt
|
||
// if the download chain fails (e.g. missing permissions).
|
||
senderID := strings.TrimSpace(callbackData.SenderStaffId)
|
||
if unionID, uerr := mediaCli.getUserUnionID(context.Background(), senderID); uerr != nil {
|
||
fmt.Fprintf(os.Stderr, "[connect][media] userId→unionId 失败 (%s): %v\n", senderID, uerr)
|
||
} else if dp, derr := mediaCli.downloadDentryFile(context.Background(), fileInfo.SpaceID, fileInfo.DentryID, unionID, fileName); derr != nil {
|
||
fmt.Fprintf(os.Stderr, "[connect][media] 钉盘文件下载失败 spaceId=%d dentryId=%d: %v\n", fileInfo.SpaceID, fileInfo.DentryID, derr)
|
||
} else {
|
||
localPath = dp
|
||
}
|
||
if localPath != "" {
|
||
if prompt == "" {
|
||
prompt = "用户发来一个文件「" + fileName + "」(本地路径 " + localPath + "),请读取文件内容并回答。"
|
||
} else {
|
||
prompt = prompt + "\n(用户同时附了一个文件「" + fileName + "」,本地路径 " + localPath + ",请结合文件内容回答。)"
|
||
}
|
||
} else {
|
||
meta := fmt.Sprintf("文件名「%s」,dentryId=%d,spaceId=%d", fileName, fileInfo.DentryID, fileInfo.SpaceID)
|
||
if fileInfo.FileType != "" {
|
||
meta += ",类型=" + fileInfo.FileType
|
||
}
|
||
if fileInfo.FileSize > 0 {
|
||
meta += fmt.Sprintf(",大小=%d 字节", fileInfo.FileSize)
|
||
}
|
||
if prompt == "" {
|
||
prompt = "用户发来一个文件(" + meta + ")。文件下载失败,请基于文件名与用户随附的文字信息回答,必要时请用户改用客户端上传或补充文字描述。"
|
||
} else {
|
||
prompt = prompt + "\n(用户同时附了一个文件:" + meta + "。文件下载失败,请结合文件名与随附文字回答。)"
|
||
}
|
||
}
|
||
default:
|
||
if prompt == "" {
|
||
prompt = "(用户发来一个文件「" + fileName + "」,但文件下载失败了。请告知用户文件没收到,建议重新发送。)"
|
||
}
|
||
}
|
||
}
|
||
if extras.kb != nil {
|
||
prompt = extras.kb.augment(prompt)
|
||
}
|
||
// Role persona: prepend the role's system-prompt fragment so the bot
|
||
// answers in its configured lane/tone. Sits above the knowledge and the
|
||
// question; a no-op when no role config supplied one.
|
||
if p := strings.TrimSpace(extras.persona); p != "" {
|
||
prompt = p + "\n\n" + prompt
|
||
}
|
||
// Confirmation gate: when active, ask the agent to declare any
|
||
// action it would take via a structured [[ACTION:...]] marker
|
||
// instead of executing it, so the orchestrator can route the
|
||
// action through the owner's approval card (see handleReply).
|
||
gateOn := extras.approval.enabled()
|
||
if gateOn {
|
||
prompt = extras.approval.decorateForActionDetection(prompt)
|
||
}
|
||
// hermes-UX reply sequence (gateway/platforms/dingtalk.py):
|
||
// ① on receive: "🤔Thinking" reaction chip on the user's message
|
||
// (no card yet — the thinking phase is the chip, not a card);
|
||
// ② agent runs;
|
||
// ③ on done: deliver the AI card with the final content;
|
||
// ④ swap the chip to "🥳Done".
|
||
// Reactions attach to the triggering message, but DingTalk rejects a
|
||
// reaction on an interactiveCard message (a bot @-mentioning this bot)
|
||
// with a 500 system.error — reactions are only supported on human
|
||
// messages. Skip the chip for those turns so we don't fire a call the
|
||
// platform always rejects; the reply itself is unaffected.
|
||
canReact := !strings.EqualFold(msgtype, "interactiveCard")
|
||
thinking := false
|
||
if cardCli != nil && canReact {
|
||
if terr := cardCli.markThinking(context.Background(), callbackData.ConversationId, msgID); terr != nil {
|
||
fmt.Fprintf(os.Stderr, "[connect][card] Thinking 表态失败(不影响回复): %v\n", terr)
|
||
} else {
|
||
thinking = true
|
||
}
|
||
}
|
||
|
||
// Streaming path: deliver the card up front and stream the agent's
|
||
// output into it as it is produced — the full hermes/openclaw UX.
|
||
// One-shot channels (or a failed card) fall back below.
|
||
var cardInst *aiCardInstance
|
||
var onDelta func(string)
|
||
sf, streamable := fwd.(streamingForwarder)
|
||
if cardCli != nil && cardCli.hasTemplate() && streamable && sf.canStream() && !gateOn {
|
||
if ci, cerr := cardCli.createAndDeliver(context.Background(), callbackData); cerr != nil {
|
||
fmt.Fprintf(os.Stderr, "[connect][card] 预投卡片失败,降级一次性回复: %v\n", cerr)
|
||
} else {
|
||
cardInst = ci
|
||
onDelta = func(sofar string) {
|
||
if serr := cardCli.streamFrame(context.Background(), ci, sofar, false); serr != nil {
|
||
fmt.Fprintf(os.Stderr, "[connect][card] 流式帧失败: %v\n", serr)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
var reply string
|
||
var err error
|
||
if streamable {
|
||
reply, err = sf.forwardStream(context.Background(), convID, prompt, onDelta)
|
||
} else {
|
||
reply, err = fwd.forward(context.Background(), convID, prompt)
|
||
}
|
||
if err != nil {
|
||
fmt.Fprintf(os.Stderr, "[connect] 转发失败 (%s, 耗时 %s): %v\n", channel, time.Since(started).Round(time.Millisecond), err)
|
||
if errors.Is(err, context.DeadlineExceeded) {
|
||
// Self-recovery: drop the conversation's session so the next
|
||
// message starts fresh instead of reusing a stuck one.
|
||
if r, ok := fwd.(sessionResetter); ok {
|
||
r.resetSession(convID)
|
||
}
|
||
reply = fmt.Sprintf("(%s 回复超时,已自动重置会话,请重试。如需调整超时上限可用 --agent-timeout)", channel)
|
||
} else {
|
||
reply = fmt.Sprintf("(%s 调用失败:%v)", channel, err)
|
||
}
|
||
health.onError(err)
|
||
} else {
|
||
fmt.Fprintf(os.Stderr, "[connect] agent 已生成回复 (%s, 耗时 %s): %s\n", channel, time.Since(started).Round(time.Millisecond), truncateRunes(reply, 80))
|
||
health.onReply()
|
||
}
|
||
|
||
// Confirmation gate orchestration: if the agent's reply declared
|
||
// an action, this submits it, sends the owner an approval card,
|
||
// blocks for the decision, executes on approve (or declines), and
|
||
// posts the outcome into the group itself. handled==true means the
|
||
// gate owns the reply and the normal delivery below is skipped.
|
||
if err == nil && gateOn {
|
||
groupReply := func(ctx context.Context, _, text string) error {
|
||
if len([]rune(text)) > 200 {
|
||
return replier.SimpleReplyMarkdown(ctx, webhook, []byte(channel), []byte(text))
|
||
}
|
||
return replier.SimpleReplyText(ctx, webhook, []byte(text))
|
||
}
|
||
out, handled := extras.approval.handleReply(context.Background(), strings.TrimSpace(callbackData.SenderStaffId), convID, reply, groupReply)
|
||
if handled {
|
||
if thinking {
|
||
cardCli.swapThinkingToDone(context.Background(), callbackData.ConversationId, msgID)
|
||
}
|
||
return
|
||
}
|
||
reply = out
|
||
}
|
||
|
||
delivered := false
|
||
if cardCli != nil && cardCli.hasTemplate() {
|
||
if cardInst == nil {
|
||
// One-shot path: card only appears with the final content.
|
||
if ci, cerr := cardCli.createAndDeliver(context.Background(), callbackData); cerr != nil {
|
||
fmt.Fprintf(os.Stderr, "[connect][card] 投放卡片失败,回退普通消息: %v\n", cerr)
|
||
} else {
|
||
cardInst = ci
|
||
}
|
||
}
|
||
if cardInst != nil {
|
||
if ferr := cardCli.finish(context.Background(), cardInst, reply); ferr != nil {
|
||
// A stuck card is the worst UX: mark it failed
|
||
// (best-effort) and fall through to the plain reply.
|
||
fmt.Fprintf(os.Stderr, "[connect][card] 卡片完成失败,回退普通消息: %v\n", ferr)
|
||
cardCli.markFailed(context.Background(), cardInst)
|
||
} else {
|
||
fmt.Fprintf(os.Stderr, "[connect][card] 卡片已完成 outTrackId=%s\n", cardInst.outTrackID)
|
||
delivered = true
|
||
// Client-side fetch of a fresh card occasionally misses
|
||
// the burst and shows "内容加载失败"; a delayed repair
|
||
// frame triggers a re-render.
|
||
go cardCli.repair(context.Background(), cardInst, reply)
|
||
}
|
||
}
|
||
}
|
||
|
||
if !delivered {
|
||
// Fallback path: plain reply via the inbound sessionWebhook.
|
||
// Retry up to 3 times with exponential backoff (1s, 2s, 4s)
|
||
// to handle transient network errors (EOF, timeout).
|
||
var sendErr error
|
||
backoffs := []time.Duration{1 * time.Second, 2 * time.Second, 4 * time.Second}
|
||
for attempt := 0; attempt <= len(backoffs); attempt++ {
|
||
if len([]rune(reply)) > 200 {
|
||
sendErr = replier.SimpleReplyMarkdown(context.Background(), webhook, []byte(channel), []byte(reply))
|
||
} else {
|
||
sendErr = replier.SimpleReplyText(context.Background(), webhook, []byte(reply))
|
||
}
|
||
if sendErr == nil {
|
||
break
|
||
}
|
||
if attempt < len(backoffs) {
|
||
fmt.Fprintf(os.Stderr, "[connect] 普通消息发送失败 (%s, attempt %d/%d, msgId=%s): %v,%v 后重试\n",
|
||
channel, attempt+1, len(backoffs)+1, msgID, sendErr, backoffs[attempt])
|
||
time.Sleep(backoffs[attempt])
|
||
}
|
||
}
|
||
if sendErr != nil {
|
||
fmt.Fprintf(os.Stderr, "[connect] 普通消息发送失败(重试耗尽) (%s, msgId=%s): %v\n", channel, msgID, sendErr)
|
||
delivered = false
|
||
} else {
|
||
fmt.Fprintf(os.Stderr, "[connect] 普通消息已发送 (%s, msgId=%s)\n", channel, msgID)
|
||
delivered = true
|
||
}
|
||
}
|
||
|
||
if thinking {
|
||
if delivered {
|
||
cardCli.swapThinkingToDone(context.Background(), callbackData.ConversationId, msgID)
|
||
} else {
|
||
fmt.Fprintf(os.Stderr, "[connect] 回复发送失败,保留思考表态不切换完成 (%s, msgId=%s)\n", channel, msgID)
|
||
}
|
||
}
|
||
})
|
||
return []byte(""), nil
|
||
})
|
||
|
||
// Confirmation gate: button taps on the owner's approval card arrive on the
|
||
// same Stream long-connection (no public webhook). Register the card callback
|
||
// router only when the gate is active; it routes [Approve]/[Reject] into
|
||
// gate.Decide, which wakes the blocked Await in the forward goroutine.
|
||
if extras.approval.enabled() {
|
||
cli.RegisterCardCallbackRouter(func(ctx context.Context, creq *card.CardRequest) (*card.CardResponse, error) {
|
||
return extras.approval.handleCardCallback(ctx, creq)
|
||
})
|
||
}
|
||
|
||
if err := cli.Start(ctx); err != nil {
|
||
return apperrors.NewInternal("stream 建连失败:" + err.Error())
|
||
}
|
||
defer cli.Close()
|
||
health.onConnected()
|
||
<-ctx.Done()
|
||
return nil
|
||
}
|
||
|
||
// envOr returns the non-empty value of env var key, otherwise def.
|
||
func envOr(key, def string) string {
|
||
if v := strings.TrimSpace(os.Getenv(key)); v != "" {
|
||
return v
|
||
}
|
||
return def
|
||
}
|
||
|
||
// envDurationMS parses an env var as a millisecond duration, falling back to
|
||
// def when missing or invalid.
|
||
func envDurationMS(key string, def time.Duration) time.Duration {
|
||
if v := strings.TrimSpace(os.Getenv(key)); v != "" {
|
||
if ms, err := strconv.Atoi(v); err == nil && ms > 0 {
|
||
return time.Duration(ms) * time.Millisecond
|
||
}
|
||
}
|
||
return def
|
||
}
|
||
|
||
// applyTimeout returns a context bounded by timeout when timeout > 0, or the
|
||
// original context unchanged when timeout == 0 (no limit). The returned cancel
|
||
// func is always safe to defer.
|
||
func applyTimeout(ctx context.Context, timeout time.Duration) (context.Context, context.CancelFunc) {
|
||
if timeout > 0 {
|
||
return context.WithTimeout(ctx, timeout)
|
||
}
|
||
return ctx, func() {}
|
||
}
|
||
|
||
// truncateRunes truncates by rune so multi-byte characters are never split.
|
||
func truncateRunes(s string, max int) string {
|
||
r := []rune(s)
|
||
if len(r) <= max {
|
||
return s
|
||
}
|
||
return string(r[:max]) + "…"
|
||
}
|