Five operational gaps that bite when a connector-backed bot answers questions in real groups: - Single-instance lock: one connector per robot per machine (pid file, stale-lock takeover). Duplicate Stream connections on one clientId get messages load-balanced between them, so the bot answers intermittently (verified live). - Access policy: --allowed-users / --allowed-groups allowlists plus a per-sender rate limit (--user-rate-limit, default 20/min). Every message is an LLM call; without this anyone who can reach the bot can spend on its behalf. - Per-conversation FIFO queue: same-chat messages run in arrival order (follow-ups need the previous turn's --resume session; parallel CLIs racing one session corrupt it); different chats stay parallel. - Picture messages: resolve the callback downloadCode via robot messageFiles/download to a local temp file and hand the path to the agent CLI — error screenshots are the top Q&A inbound and were previously dropped silently. The claudecode persona now permits reading attached files. - --knowledge-dir: lightweight local retrieval (markdown chunking, latin-token + CJK-bigram scoring) prepends top-k matching chunks to each question while the agent keeps running from the clean scratch dir, so replies stay inside DingTalk's AI-assistant response window. All knobs have env mirrors (DWS_ALLOWED_USERS / DWS_ALLOWED_GROUPS / DWS_USER_RATE_LIMIT / DWS_KNOWLEDGE_DIR) for service-style runs. go build ./... clean; go test ./internal/helpers -race passes with new tests for lock/gate/queue/knowledge/media. test/cli_compat, test/scripts and test/unit fail identically on the unmodified base (pre-existing fixture issues).
103 lines
3.0 KiB
Go
103 lines
3.0 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 (
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func withLockDir(t *testing.T) string {
|
|
t.Helper()
|
|
old := connectLockDir
|
|
dir := t.TempDir()
|
|
connectLockDir = dir
|
|
t.Cleanup(func() { connectLockDir = old })
|
|
return dir
|
|
}
|
|
|
|
// TestConnectLockBlocksSecond: a live lock (this test process's pid) must
|
|
// reject a second acquire for the same robot, with the dual-connection
|
|
// explanation in the error.
|
|
func TestConnectLockBlocksSecond(t *testing.T) {
|
|
withLockDir(t)
|
|
release, err := acquireConnectLock("ding-bot-1")
|
|
if err != nil {
|
|
t.Fatalf("first acquire: %v", err)
|
|
}
|
|
defer release()
|
|
|
|
if _, err := acquireConnectLock("ding-bot-1"); err == nil {
|
|
t.Fatal("second acquire succeeded, want rejection")
|
|
} else if !strings.Contains(err.Error(), "已有连接器在本机运行") {
|
|
t.Fatalf("error = %v, want dual-connection explanation", err)
|
|
}
|
|
|
|
// A different robot is unaffected.
|
|
release2, err := acquireConnectLock("ding-bot-2")
|
|
if err != nil {
|
|
t.Fatalf("other robot acquire: %v", err)
|
|
}
|
|
release2()
|
|
}
|
|
|
|
// TestConnectLockReleaseAndReacquire: release removes the pid file and the
|
|
// same robot can be locked again.
|
|
func TestConnectLockReleaseAndReacquire(t *testing.T) {
|
|
dir := withLockDir(t)
|
|
release, err := acquireConnectLock("ding-bot-1")
|
|
if err != nil {
|
|
t.Fatalf("acquire: %v", err)
|
|
}
|
|
release()
|
|
if entries, _ := os.ReadDir(dir); len(entries) != 0 {
|
|
t.Fatalf("lock file not removed: %v", entries)
|
|
}
|
|
release2, err := acquireConnectLock("ding-bot-1")
|
|
if err != nil {
|
|
t.Fatalf("reacquire after release: %v", err)
|
|
}
|
|
release2()
|
|
}
|
|
|
|
// TestConnectLockStaleTakeover: a lock owned by a dead pid is taken over.
|
|
func TestConnectLockStaleTakeover(t *testing.T) {
|
|
dir := withLockDir(t)
|
|
// pid far beyond any OS pid range — guaranteed not alive.
|
|
stale := filepath.Join(dir, "dws-connect-ding-bot-1.pid")
|
|
if err := os.WriteFile(stale, []byte("999999999"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
release, err := acquireConnectLock("ding-bot-1")
|
|
if err != nil {
|
|
t.Fatalf("stale takeover failed: %v", err)
|
|
}
|
|
release()
|
|
}
|
|
|
|
// TestConnectLockGarbageFile: an unreadable/garbage lock is treated as stale.
|
|
func TestConnectLockGarbageFile(t *testing.T) {
|
|
dir := withLockDir(t)
|
|
if err := os.WriteFile(filepath.Join(dir, "dws-connect-ding-bot-1.pid"), []byte("not-a-pid"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
release, err := acquireConnectLock("ding-bot-1")
|
|
if err != nil {
|
|
t.Fatalf("garbage lock takeover failed: %v", err)
|
|
}
|
|
release()
|
|
}
|