mirror of
https://github.com/chenhg5/cc-connect.git
synced 2026-10-25 17:36:43 +08:00
fix(core): multiSelect AskUserQuestion on card platforms (closes #1184) - For multiSelect questions, render options as a numbered text list instead of instant-resolve buttons (buttons resolved on first click, preventing multi-selection) - Add new i18n key MsgAskQuestionNoteMulti with per-language hint to reply with comma-separated numbers (e.g. 1,3) - Single-select questions keep the existing button UX unchanged fix(config): allow cc-connect web with agent-only config (closes #1139) - Add LoadPermissive() / validatePermissive() that skip the "at least one platform" requirement - runWeb now uses LoadPermissive so the web admin UI starts even before any platforms are configured, enabling first-time setup fix(weixin): fail fast on ret=-2 when context_token cannot be refreshed (closes #1176) - When sendMessage returns ret=-2 and the stored token is the same as the current one (no inbound message has refreshed it), stop retrying immediately instead of burning 3 retries on the same stale token - Return a clear actionable error: "user must send a new message to refresh the session token" - Applied to both text (weixin.go) and media (media_outbound.go) paths Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
92add53514
commit
eb86866420
6 files changed
+100
-28
No files matched your search
@@ -19,7 +19,9 @@ func runWeb(args []string) {
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
cfg, err := config.Load(configPath)
|
||||
// Use LoadPermissive so `cc-connect web` works even before any platforms
|
||||
// are configured (e.g. during initial setup via the Web Admin UI).
|
||||
cfg, err := config.LoadPermissive(configPath)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error loading config: %v\n", err)
|
||||
os.Exit(1)
|
||||
|
||||
+37
-8
@@ -470,20 +470,18 @@ type LogConfig struct {
|
||||
Level string `toml:"level"`
|
||||
}
|
||||
|
||||
func Load(path string) (*Config, error) {
|
||||
// load parses, env-resolves, and wires providers in the config file but does
|
||||
// NOT validate — callers must call validate() or validatePermissive() themselves.
|
||||
func load(path string) (*Config, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read config file: %w", err)
|
||||
}
|
||||
|
||||
cfg := &Config{
|
||||
Log: LogConfig{Level: "info"},
|
||||
}
|
||||
cfg := &Config{Log: LogConfig{Level: "info"}}
|
||||
if err := toml.Unmarshal(data, cfg); err != nil {
|
||||
return nil, fmt.Errorf("parse config: %w", err)
|
||||
}
|
||||
resolveEnvInConfig(cfg)
|
||||
|
||||
if cfg.DataDir == "" {
|
||||
if home, err := os.UserHomeDir(); err == nil {
|
||||
cfg.DataDir = filepath.Join(home, ".cc-connect")
|
||||
@@ -495,9 +493,29 @@ func Load(path string) (*Config, error) {
|
||||
if cfg.AttachmentSend == "" {
|
||||
cfg.AttachmentSend = "on"
|
||||
}
|
||||
|
||||
cfg.ResolveProviderRefs()
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// LoadPermissive loads the config file and performs all validation except the
|
||||
// "at least one platform per project" check. Use this for commands (like
|
||||
// `cc-connect web`) that should work even before platforms are configured.
|
||||
func LoadPermissive(path string) (*Config, error) {
|
||||
cfg, err := load(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := cfg.validatePermissive(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func Load(path string) (*Config, error) {
|
||||
cfg, err := load(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := cfg.validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -777,7 +795,18 @@ func EffectiveCardMode(cfg *Config, proj *ProjectConfig) string {
|
||||
return "legacy"
|
||||
}
|
||||
|
||||
// validatePermissive is like validate but skips the "at least one platform"
|
||||
// requirement so that commands like `cc-connect web` can operate on agent-only
|
||||
// configs before platforms have been set up.
|
||||
func (c *Config) validatePermissive() error {
|
||||
return c.validateInternal(true)
|
||||
}
|
||||
|
||||
func (c *Config) validate() error {
|
||||
return c.validateInternal(false)
|
||||
}
|
||||
|
||||
func (c *Config) validateInternal(permissive bool) error {
|
||||
if err := validateDisplayConfig("display", &c.Display); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -805,7 +834,7 @@ func (c *Config) validate() error {
|
||||
if proj.Agent.Type == "" {
|
||||
return fmt.Errorf("config: %s.agent.type is required", prefix)
|
||||
}
|
||||
if len(proj.Platforms) == 0 {
|
||||
if len(proj.Platforms) == 0 && !permissive {
|
||||
return fmt.Errorf("config: %s needs at least one [[projects.platforms]]", prefix)
|
||||
}
|
||||
for j, p := range proj.Platforms {
|
||||
|
||||
+14
-1
@@ -9630,8 +9630,20 @@ func (e *Engine) sendAskQuestionPrompt(p Platform, replyCtx any, questions []Use
|
||||
cb := NewCard().Title(e.i18n.T(MsgAskQuestionTitle)+titleSuffix, "blue")
|
||||
body := "**" + q.Question + "**"
|
||||
if q.MultiSelect {
|
||||
body += e.i18n.T(MsgAskQuestionMulti)
|
||||
// For multiSelect, buttons would resolve on the first click and prevent
|
||||
// selecting multiple options. Render options as a numbered text list
|
||||
// instead, and instruct the user to reply with comma-separated numbers.
|
||||
body += e.i18n.T(MsgAskQuestionMulti) + "\n\n"
|
||||
for i, opt := range q.Options {
|
||||
body += fmt.Sprintf("%d. **%s**", i+1, opt.Label)
|
||||
if opt.Description != "" {
|
||||
body += " — " + opt.Description
|
||||
}
|
||||
body += "\n"
|
||||
}
|
||||
cb.Markdown(body)
|
||||
cb.Note(e.i18n.T(MsgAskQuestionNoteMulti))
|
||||
} else {
|
||||
cb.Markdown(body)
|
||||
for i, opt := range q.Options {
|
||||
desc := opt.Label
|
||||
@@ -9645,6 +9657,7 @@ func (e *Engine) sendAskQuestionPrompt(p Platform, replyCtx any, questions []Use
|
||||
})
|
||||
}
|
||||
cb.Note(e.i18n.T(MsgAskQuestionNote))
|
||||
}
|
||||
e.sendWithCard(p, replyCtx, cb.Build())
|
||||
return
|
||||
}
|
||||
|
||||
@@ -392,6 +392,7 @@ const (
|
||||
|
||||
MsgAskQuestionTitle MsgKey = "ask_question_title"
|
||||
MsgAskQuestionNote MsgKey = "ask_question_note"
|
||||
MsgAskQuestionNoteMulti MsgKey = "ask_question_note_multi"
|
||||
MsgAskQuestionMulti MsgKey = "ask_question_multi"
|
||||
MsgAskQuestionPrompt MsgKey = "ask_question_prompt"
|
||||
MsgAskQuestionAnswered MsgKey = "ask_question_answered"
|
||||
@@ -2518,6 +2519,13 @@ var messages = map[MsgKey]map[Language]string{
|
||||
LangJapanese: "ボタンが反応しない場合は、番号(例: 1)で返信するか、直接回答を入力してください",
|
||||
LangSpanish: "Si los botones no responden, responda con el número de opción (ej. 1) o escriba su respuesta",
|
||||
},
|
||||
MsgAskQuestionNoteMulti: {
|
||||
LangEnglish: "Reply with comma-separated option numbers (e.g. 1,3) or type your answer",
|
||||
LangChinese: "请回复逗号分隔的选项编号(如 1,3)或直接输入你的回答",
|
||||
LangTraditionalChinese: "請回覆逗號分隔的選項編號(如 1,3)或直接輸入你的回答",
|
||||
LangJapanese: "カンマ区切りの番号(例: 1,3)で返信するか、直接回答を入力してください",
|
||||
LangSpanish: "Responda con los números de opción separados por comas (ej. 1,3) o escriba su respuesta",
|
||||
},
|
||||
MsgAskQuestionMulti: {
|
||||
LangEnglish: " (multiple selections allowed, separate with commas)",
|
||||
LangChinese: "(可多选,用逗号分隔)",
|
||||
|
||||
@@ -151,22 +151,27 @@ func (p *Platform) sendSingleItemWithRetry(ctx context.Context, rc *replyContext
|
||||
return nil
|
||||
}
|
||||
lastErr = err
|
||||
// Check if error is ret=-2 (API declined) - retry with fresh token
|
||||
// Check if error is ret=-2 (API declined) - attempt token refresh
|
||||
if strings.Contains(err.Error(), "ret=-2") {
|
||||
slog.Warn("weixin: sendMessage ret=-2 for media, retrying",
|
||||
freshToken := p.getContextToken(rc.peerUserID)
|
||||
if freshToken == "" || freshToken == rc.contextToken {
|
||||
slog.Warn("weixin: sendMessage ret=-2 for media, no fresh context_token — "+
|
||||
"user must send a new message to refresh session token",
|
||||
"attempt", attempt+1, "peer", rc.peerUserID)
|
||||
// Add delay before retry
|
||||
return fmt.Errorf("weixin: sendMessage ret=-2 (expired context_token); "+
|
||||
"user must send a new message to peer %q to refresh the session token: %w",
|
||||
rc.peerUserID, lastErr)
|
||||
}
|
||||
slog.Warn("weixin: sendMessage ret=-2 for media, retrying with fresh context_token",
|
||||
"attempt", attempt+1, "peer", rc.peerUserID)
|
||||
rc.contextToken = freshToken
|
||||
slog.Debug("weixin: using refreshed context_token for media retry", "peer", rc.peerUserID)
|
||||
// Brief delay before retry
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(weixinSendRetryDelay):
|
||||
}
|
||||
// Refresh context_token from stored tokens
|
||||
freshToken := p.getContextToken(rc.peerUserID)
|
||||
if freshToken != "" && freshToken != rc.contextToken {
|
||||
rc.contextToken = freshToken
|
||||
slog.Debug("weixin: using refreshed context_token for media retry", "peer", rc.peerUserID)
|
||||
}
|
||||
continue
|
||||
}
|
||||
// For other errors, don't retry
|
||||
|
||||
@@ -655,7 +655,10 @@ func (p *Platform) sendChunks(ctx context.Context, replyCtx any, content string)
|
||||
}
|
||||
|
||||
// sendChunkWithRetry sends a single chunk with retry mechanism.
|
||||
// When sendMessage returns ret=-2, it retries with a fresh context_token.
|
||||
// When sendMessage returns ret=-2, it tries to refresh the context_token from
|
||||
// storage (which is updated by every inbound message) before retrying.
|
||||
// If the stored token is the same as the current one (no refresh possible),
|
||||
// it fails fast rather than burning retries on a stale token.
|
||||
// chunkIdx and totalChunks are 1-based indices used for logging context.
|
||||
func (p *Platform) sendChunkWithRetry(ctx context.Context, rc *replyContext, chunk string, chunkIdx, totalChunks int) error {
|
||||
var lastErr error
|
||||
@@ -666,29 +669,41 @@ func (p *Platform) sendChunkWithRetry(ctx context.Context, rc *replyContext, chu
|
||||
return nil
|
||||
}
|
||||
lastErr = err
|
||||
// Check if error is ret=-2 (API declined) - retry with fresh token
|
||||
// Check if error is ret=-2 (API declined) - attempt token refresh
|
||||
if strings.Contains(err.Error(), "ret=-2") {
|
||||
preview := []rune(chunk)
|
||||
if len(preview) > 50 {
|
||||
preview = preview[:50]
|
||||
}
|
||||
// Refresh context_token from stored tokens (may have been updated by a
|
||||
// concurrent inbound message while we were waiting).
|
||||
freshToken := p.getContextToken(rc.peerUserID)
|
||||
if freshToken == "" || freshToken == rc.contextToken {
|
||||
// No fresh token available — further retries would use the same stale
|
||||
// token and all fail. Fail fast with an actionable error.
|
||||
slog.Warn("weixin: sendMessage ret=-2, no fresh context_token available — "+
|
||||
"user must send a new message to refresh the session token",
|
||||
"attempt", attempt+1, "peer", rc.peerUserID,
|
||||
"chunk", fmt.Sprintf("%d/%d", chunkIdx, totalChunks),
|
||||
"chunk_runes", utf8.RuneCountInString(chunk),
|
||||
"preview", string(preview))
|
||||
return fmt.Errorf("weixin: sendMessage ret=-2 (expired context_token); "+
|
||||
"user must send a new message to peer %q to refresh the session token: %w",
|
||||
rc.peerUserID, lastErr)
|
||||
}
|
||||
slog.Warn("weixin: sendMessage ret=-2, retrying with fresh context_token",
|
||||
"attempt", attempt+1, "peer", rc.peerUserID,
|
||||
"chunk", fmt.Sprintf("%d/%d", chunkIdx, totalChunks),
|
||||
"chunk_runes", utf8.RuneCountInString(chunk),
|
||||
"preview", string(preview))
|
||||
// Add delay before retry
|
||||
rc.contextToken = freshToken
|
||||
slog.Debug("weixin: using refreshed context_token for retry", "peer", rc.peerUserID)
|
||||
// Brief delay before retry
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(weixinSendRetryDelay):
|
||||
}
|
||||
// Refresh context_token from stored tokens (may have been updated by new incoming message)
|
||||
freshToken := p.getContextToken(rc.peerUserID)
|
||||
if freshToken != "" && freshToken != rc.contextToken {
|
||||
rc.contextToken = freshToken
|
||||
slog.Debug("weixin: using refreshed context_token for retry", "peer", rc.peerUserID)
|
||||
}
|
||||
continue
|
||||
}
|
||||
// For other errors, don't retry
|
||||
|
||||
Reference in new issue
Block a user