Merge branch 'main' into nanogpt
This commit is contained in:
106 files changed
+3061
-210
No files matched your search
@@ -61,7 +61,7 @@ describe("runClaudeCliAgent", () => {
|
||||
expect(argv).toContain("hi");
|
||||
});
|
||||
|
||||
it("uses provided --session-id when a claude session id is provided", async () => {
|
||||
it("uses --resume when a claude session id is provided", async () => {
|
||||
runCommandWithTimeoutMock.mockResolvedValueOnce({
|
||||
stdout: JSON.stringify({ message: "ok", session_id: "sid-2" }),
|
||||
stderr: "",
|
||||
@@ -83,7 +83,7 @@ describe("runClaudeCliAgent", () => {
|
||||
|
||||
expect(runCommandWithTimeoutMock).toHaveBeenCalledTimes(1);
|
||||
const argv = runCommandWithTimeoutMock.mock.calls[0]?.[0] as string[];
|
||||
expect(argv).toContain("--session-id");
|
||||
expect(argv).toContain("--resume");
|
||||
expect(argv).toContain("c9d7b831-1c31-4d22-80b9-1e50ca207d4b");
|
||||
expect(argv).toContain("hi");
|
||||
});
|
||||
|
||||
@@ -28,6 +28,14 @@ const CLAUDE_MODEL_ALIASES: Record<string, string> = {
|
||||
const DEFAULT_CLAUDE_BACKEND: CliBackendConfig = {
|
||||
command: "claude",
|
||||
args: ["-p", "--output-format", "json", "--dangerously-skip-permissions"],
|
||||
resumeArgs: [
|
||||
"-p",
|
||||
"--output-format",
|
||||
"json",
|
||||
"--dangerously-skip-permissions",
|
||||
"--resume",
|
||||
"{sessionId}",
|
||||
],
|
||||
output: "json",
|
||||
input: "arg",
|
||||
modelArg: "--model",
|
||||
|
||||
@@ -133,8 +133,50 @@ export function createCronTool(opts?: CronToolOptions): AnyAgentTool {
|
||||
return {
|
||||
label: "Cron",
|
||||
name: "cron",
|
||||
description:
|
||||
"Manage Gateway cron jobs (status/list/add/update/remove/run/runs) and send wake events. Use `jobId` as the canonical identifier; `id` is accepted for compatibility. Use `contextMessages` (0-10) to add previous messages as context to the job text.",
|
||||
description: `Manage Gateway cron jobs (status/list/add/update/remove/run/runs) and send wake events.
|
||||
|
||||
ACTIONS:
|
||||
- status: Check cron scheduler status
|
||||
- list: List jobs (use includeDisabled:true to include disabled)
|
||||
- add: Create job (requires job object, see schema below)
|
||||
- update: Modify job (requires jobId + patch object)
|
||||
- remove: Delete job (requires jobId)
|
||||
- run: Trigger job immediately (requires jobId)
|
||||
- runs: Get job run history (requires jobId)
|
||||
- wake: Send wake event (requires text, optional mode)
|
||||
|
||||
JOB SCHEMA (for add action):
|
||||
{
|
||||
"name": "string (optional)",
|
||||
"schedule": { ... }, // Required: when to run
|
||||
"payload": { ... }, // Required: what to execute
|
||||
"sessionTarget": "main" | "isolated", // Required
|
||||
"enabled": true | false // Optional, default true
|
||||
}
|
||||
|
||||
SCHEDULE TYPES (schedule.kind):
|
||||
- "at": One-shot at absolute time
|
||||
{ "kind": "at", "atMs": <unix-ms-timestamp> }
|
||||
- "every": Recurring interval
|
||||
{ "kind": "every", "everyMs": <interval-ms>, "anchorMs": <optional-start-ms> }
|
||||
- "cron": Cron expression
|
||||
{ "kind": "cron", "expr": "<cron-expression>", "tz": "<optional-timezone>" }
|
||||
|
||||
PAYLOAD TYPES (payload.kind):
|
||||
- "systemEvent": Injects text as system event into session
|
||||
{ "kind": "systemEvent", "text": "<message>" }
|
||||
- "agentTurn": Runs agent with message (isolated sessions only)
|
||||
{ "kind": "agentTurn", "message": "<prompt>", "model": "<optional>", "thinking": "<optional>", "timeoutSeconds": <optional>, "deliver": <optional-bool>, "channel": "<optional>", "to": "<optional>", "bestEffortDeliver": <optional-bool> }
|
||||
|
||||
CRITICAL CONSTRAINTS:
|
||||
- sessionTarget="main" REQUIRES payload.kind="systemEvent"
|
||||
- sessionTarget="isolated" REQUIRES payload.kind="agentTurn"
|
||||
|
||||
WAKE MODES (for wake action):
|
||||
- "next-heartbeat" (default): Wake on next heartbeat
|
||||
- "now": Wake immediately
|
||||
|
||||
Use jobId as the canonical identifier; id is accepted for compatibility. Use contextMessages (0-10) to add previous messages as context to the job text.`,
|
||||
parameters: CronToolSchema,
|
||||
execute: async (_toolCallId, args) => {
|
||||
const params = args as Record<string, unknown>;
|
||||
|
||||
@@ -14,6 +14,7 @@ export type AnnounceTarget = {
|
||||
channel: string;
|
||||
to: string;
|
||||
accountId?: string;
|
||||
threadId?: string; // Forum topic/thread ID
|
||||
};
|
||||
|
||||
export function resolveAnnounceTargetFromKey(sessionKey: string): AnnounceTarget | null {
|
||||
@@ -22,7 +23,22 @@ export function resolveAnnounceTargetFromKey(sessionKey: string): AnnounceTarget
|
||||
if (parts.length < 3) return null;
|
||||
const [channelRaw, kind, ...rest] = parts;
|
||||
if (kind !== "group" && kind !== "channel") return null;
|
||||
const id = rest.join(":").trim();
|
||||
|
||||
// Extract topic/thread ID from rest (supports both :topic: and :thread:)
|
||||
// Telegram uses :topic:, other platforms use :thread:
|
||||
let threadId: string | undefined;
|
||||
const restJoined = rest.join(":");
|
||||
const topicMatch = restJoined.match(/:topic:(\d+)$/);
|
||||
const threadMatch = restJoined.match(/:thread:(\d+)$/);
|
||||
const match = topicMatch || threadMatch;
|
||||
|
||||
if (match) {
|
||||
threadId = match[1]; // Keep as string to match AgentCommandOpts.threadId
|
||||
}
|
||||
|
||||
// Remove :topic:N or :thread:N suffix from ID for target
|
||||
const id = match ? restJoined.replace(/:(topic|thread):\d+$/, "") : restJoined.trim();
|
||||
|
||||
if (!id) return null;
|
||||
if (!channelRaw) return null;
|
||||
const normalizedChannel = normalizeAnyChannelId(channelRaw) ?? normalizeChatChannelId(channelRaw);
|
||||
@@ -37,7 +53,11 @@ export function resolveAnnounceTargetFromKey(sessionKey: string): AnnounceTarget
|
||||
const normalized = normalizedChannel
|
||||
? getChannelPlugin(normalizedChannel)?.messaging?.normalizeTarget?.(kindTarget)
|
||||
: undefined;
|
||||
return { channel, to: normalized ?? kindTarget };
|
||||
return {
|
||||
channel,
|
||||
to: normalized ?? kindTarget,
|
||||
threadId,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildAgentToAgentMessageContext(params: {
|
||||
|
||||
@@ -179,6 +179,17 @@ export async function runAgentTurnWithFallback(params: {
|
||||
images: params.opts?.images,
|
||||
})
|
||||
.then((result) => {
|
||||
// CLI backends don't emit streaming assistant events, so we need to
|
||||
// emit one with the final text so server-chat can populate its buffer
|
||||
// and send the response to TUI/WebSocket clients.
|
||||
const cliText = result.payloads?.[0]?.text?.trim();
|
||||
if (cliText) {
|
||||
emitAgentEvent({
|
||||
runId,
|
||||
stream: "assistant",
|
||||
data: { text: cliText },
|
||||
});
|
||||
}
|
||||
emitAgentEvent({
|
||||
runId,
|
||||
stream: "lifecycle",
|
||||
@@ -358,12 +369,13 @@ export async function runAgentTurnWithFallback(params: {
|
||||
// Use pipeline if available (block streaming enabled), otherwise send directly
|
||||
if (params.blockStreamingEnabled && params.blockReplyPipeline) {
|
||||
params.blockReplyPipeline.enqueue(blockPayload);
|
||||
} else {
|
||||
// Send directly when flushing before tool execution (no streaming).
|
||||
} else if (params.blockStreamingEnabled) {
|
||||
// Send directly when flushing before tool execution (no pipeline but streaming enabled).
|
||||
// Track sent key to avoid duplicate in final payloads.
|
||||
directlySentBlockKeys.add(createBlockReplyPayloadKey(blockPayload));
|
||||
await params.opts?.onBlockReply?.(blockPayload);
|
||||
}
|
||||
// When streaming is disabled entirely, blocks are accumulated in final text instead.
|
||||
}
|
||||
: undefined,
|
||||
onBlockReplyFlush:
|
||||
|
||||
@@ -337,12 +337,56 @@ async function pageTargetId(page: Page): Promise<string | null> {
|
||||
}
|
||||
}
|
||||
|
||||
async function findPageByTargetId(browser: Browser, targetId: string): Promise<Page | null> {
|
||||
async function findPageByTargetId(
|
||||
browser: Browser,
|
||||
targetId: string,
|
||||
cdpUrl?: string,
|
||||
): Promise<Page | null> {
|
||||
const pages = await getAllPages(browser);
|
||||
// First, try the standard CDP session approach
|
||||
for (const page of pages) {
|
||||
const tid = await pageTargetId(page).catch(() => null);
|
||||
if (tid && tid === targetId) return page;
|
||||
}
|
||||
// If CDP sessions fail (e.g., extension relay blocks Target.attachToBrowserTarget),
|
||||
// fall back to URL-based matching using the /json/list endpoint
|
||||
if (cdpUrl) {
|
||||
try {
|
||||
const baseUrl = cdpUrl
|
||||
.replace(/\/+$/, "")
|
||||
.replace(/^ws:/, "http:")
|
||||
.replace(/\/cdp$/, "");
|
||||
const response = await fetch(`${baseUrl}/json/list`);
|
||||
if (response.ok) {
|
||||
const targets = (await response.json()) as Array<{
|
||||
id: string;
|
||||
url: string;
|
||||
title?: string;
|
||||
}>;
|
||||
const target = targets.find((t) => t.id === targetId);
|
||||
if (target) {
|
||||
// Try to find a page with matching URL
|
||||
const urlMatch = pages.filter((p) => p.url() === target.url);
|
||||
if (urlMatch.length === 1) {
|
||||
return urlMatch[0];
|
||||
}
|
||||
// If multiple URL matches, use index-based matching as fallback
|
||||
// This works when Playwright and the relay enumerate tabs in the same order
|
||||
if (urlMatch.length > 1) {
|
||||
const sameUrlTargets = targets.filter((t) => t.url === target.url);
|
||||
if (sameUrlTargets.length === urlMatch.length) {
|
||||
const idx = sameUrlTargets.findIndex((t) => t.id === targetId);
|
||||
if (idx >= 0 && idx < urlMatch.length) {
|
||||
return urlMatch[idx];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore fetch errors and fall through to return null
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -355,7 +399,7 @@ export async function getPageForTargetId(opts: {
|
||||
if (!pages.length) throw new Error("No pages available in the connected browser.");
|
||||
const first = pages[0];
|
||||
if (!opts.targetId) return first;
|
||||
const found = await findPageByTargetId(browser, opts.targetId);
|
||||
const found = await findPageByTargetId(browser, opts.targetId, opts.cdpUrl);
|
||||
if (!found) {
|
||||
// Extension relays can block CDP attachment APIs (e.g. Target.attachToBrowserTarget),
|
||||
// which prevents us from resolving a page's targetId via newCDPSession(). If Playwright
|
||||
@@ -496,7 +540,7 @@ export async function closePageByTargetIdViaPlaywright(opts: {
|
||||
targetId: string;
|
||||
}): Promise<void> {
|
||||
const { browser } = await connectBrowser(opts.cdpUrl);
|
||||
const page = await findPageByTargetId(browser, opts.targetId);
|
||||
const page = await findPageByTargetId(browser, opts.targetId, opts.cdpUrl);
|
||||
if (!page) {
|
||||
throw new Error("tab not found");
|
||||
}
|
||||
@@ -512,7 +556,7 @@ export async function focusPageByTargetIdViaPlaywright(opts: {
|
||||
targetId: string;
|
||||
}): Promise<void> {
|
||||
const { browser } = await connectBrowser(opts.cdpUrl);
|
||||
const page = await findPageByTargetId(browser, opts.targetId);
|
||||
const page = await findPageByTargetId(browser, opts.targetId, opts.cdpUrl);
|
||||
if (!page) {
|
||||
throw new Error("tab not found");
|
||||
}
|
||||
|
||||
@@ -13,11 +13,9 @@ const providerId = "telegram";
|
||||
function readTelegramSendParams(params: Record<string, unknown>) {
|
||||
const to = readStringParam(params, "to", { required: true });
|
||||
const mediaUrl = readStringParam(params, "media", { trim: false });
|
||||
const content =
|
||||
readStringParam(params, "message", {
|
||||
required: !mediaUrl,
|
||||
allowEmpty: true,
|
||||
}) ?? "";
|
||||
const message = readStringParam(params, "message", { required: !mediaUrl, allowEmpty: true });
|
||||
const caption = readStringParam(params, "caption", { allowEmpty: true });
|
||||
const content = message || caption || "";
|
||||
const replyTo = readStringParam(params, "replyTo");
|
||||
const threadId = readStringParam(params, "threadId");
|
||||
const buttons = params.buttons;
|
||||
|
||||
@@ -52,7 +52,7 @@ export function registerOnboardCommand(program: Command) {
|
||||
.option("--mode <mode>", "Wizard mode: local|remote")
|
||||
.option(
|
||||
"--auth-choice <choice>",
|
||||
"Auth: setup-token|claude-cli|token|chutes|openai-codex|openai-api-key|openrouter-api-key|ai-gateway-api-key|moonshot-api-key|kimi-code-api-key|synthetic-api-key|nanogpt-api-key|codex-cli|gemini-api-key|zai-api-key|apiKey|minimax-api|minimax-api-lightning|opencode-zen|skip",
|
||||
"Auth: setup-token|claude-cli|token|chutes|openai-codex|openai-api-key|openrouter-api-key|ai-gateway-api-key|moonshot-api-key|kimi-code-api-key|synthetic-api-key|nanogpt-api-key|venice-api-key|codex-cli|gemini-api-key|zai-api-key|apiKey|minimax-api|minimax-api-lightning|opencode-zen|skip",
|
||||
)
|
||||
.option(
|
||||
"--token-provider <id>",
|
||||
@@ -75,6 +75,7 @@ export function registerOnboardCommand(program: Command) {
|
||||
.option("--minimax-api-key <key>", "MiniMax API key")
|
||||
.option("--synthetic-api-key <key>", "Synthetic API key")
|
||||
.option("--nanogpt-api-key <key>", "NanoGPT API key")
|
||||
.option("--venice-api-key <key>", "Venice API key")
|
||||
.option("--opencode-zen-api-key <key>", "OpenCode Zen API key")
|
||||
.option("--gateway-port <port>", "Gateway port")
|
||||
.option("--gateway-bind <mode>", "Gateway bind: loopback|tailnet|lan|auto|custom")
|
||||
@@ -125,6 +126,7 @@ export function registerOnboardCommand(program: Command) {
|
||||
minimaxApiKey: opts.minimaxApiKey as string | undefined,
|
||||
syntheticApiKey: opts.syntheticApiKey as string | undefined,
|
||||
nanogptApiKey: opts.nanogptApiKey as string | undefined,
|
||||
veniceApiKey: opts.veniceApiKey as string | undefined,
|
||||
opencodeZenApiKey: opts.opencodeZenApiKey as string | undefined,
|
||||
gatewayPort:
|
||||
typeof gatewayPort === "number" && Number.isFinite(gatewayPort)
|
||||
|
||||
@@ -10,6 +10,61 @@ export async function noteSecurityWarnings(cfg: ClawdbotConfig) {
|
||||
const warnings: string[] = [];
|
||||
const auditHint = `- Run: ${formatCliCommand("clawdbot security audit --deep")}`;
|
||||
|
||||
// ===========================================
|
||||
// GATEWAY NETWORK EXPOSURE CHECK
|
||||
// ===========================================
|
||||
// Check for dangerous gateway binding configurations
|
||||
// that expose the gateway to network without proper auth
|
||||
|
||||
const gatewayBind = cfg.gateway?.bind ?? "loopback";
|
||||
const customBindHost = cfg.gateway?.customBindHost?.trim();
|
||||
const authMode = cfg.gateway?.auth?.mode ?? "off";
|
||||
const authToken = cfg.gateway?.auth?.token;
|
||||
const authPassword = cfg.gateway?.auth?.password;
|
||||
|
||||
const isLoopbackBindHost = (host: string) => {
|
||||
const normalized = host.trim().toLowerCase();
|
||||
return (
|
||||
normalized === "localhost" ||
|
||||
normalized === "::1" ||
|
||||
normalized === "[::1]" ||
|
||||
normalized.startsWith("127.")
|
||||
);
|
||||
};
|
||||
|
||||
// Bindings that expose gateway beyond localhost
|
||||
const exposedBindings = ["all", "lan", "0.0.0.0"];
|
||||
const isExposed =
|
||||
exposedBindings.includes(gatewayBind) ||
|
||||
(gatewayBind === "custom" && (!customBindHost || !isLoopbackBindHost(customBindHost)));
|
||||
|
||||
if (isExposed) {
|
||||
if (authMode === "off") {
|
||||
warnings.push(
|
||||
`- CRITICAL: Gateway bound to "${gatewayBind}" with NO authentication.`,
|
||||
` Anyone on your network (or internet if port-forwarded) can fully control your agent.`,
|
||||
` Fix: ${formatCliCommand("clawdbot config set gateway.bind loopback")}`,
|
||||
` Or enable auth: ${formatCliCommand("clawdbot config set gateway.auth.mode token")}`,
|
||||
);
|
||||
} else if (authMode === "token" && !authToken) {
|
||||
warnings.push(
|
||||
`- CRITICAL: Gateway bound to "${gatewayBind}" with empty auth token.`,
|
||||
` Fix: ${formatCliCommand("clawdbot doctor --fix")} to generate a token`,
|
||||
);
|
||||
} else if (authMode === "password" && !authPassword) {
|
||||
warnings.push(
|
||||
`- CRITICAL: Gateway bound to "${gatewayBind}" with empty password.`,
|
||||
` Fix: ${formatCliCommand("clawdbot configure")} to set a password`,
|
||||
);
|
||||
} else {
|
||||
// Auth is configured, but still warn about network exposure
|
||||
warnings.push(
|
||||
`- WARNING: Gateway bound to "${gatewayBind}" (network-accessible).`,
|
||||
` Ensure your auth credentials are strong and not exposed.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const warnDmPolicy = async (params: {
|
||||
label: string;
|
||||
provider: ChannelId;
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
applyOpencodeZenConfig,
|
||||
applyOpenrouterConfig,
|
||||
applySyntheticConfig,
|
||||
applyVeniceConfig,
|
||||
applyVercelAiGatewayConfig,
|
||||
applyZaiConfig,
|
||||
setAnthropicApiKey,
|
||||
@@ -32,6 +33,7 @@ import {
|
||||
setOpencodeZenApiKey,
|
||||
setOpenrouterApiKey,
|
||||
setSyntheticApiKey,
|
||||
setVeniceApiKey,
|
||||
setVercelAiGatewayApiKey,
|
||||
setZaiApiKey,
|
||||
} from "../../onboard-auth.js";
|
||||
@@ -292,6 +294,25 @@ export async function applyNonInteractiveAuthChoice(params: {
|
||||
});
|
||||
return applyNanoGptConfig(nextConfig);
|
||||
}
|
||||
|
||||
if (authChoice === "venice-api-key") {
|
||||
const resolved = await resolveNonInteractiveApiKey({
|
||||
provider: "venice",
|
||||
cfg: baseConfig,
|
||||
flagValue: opts.veniceApiKey,
|
||||
flagName: "--venice-api-key",
|
||||
envVar: "VENICE_API_KEY",
|
||||
runtime,
|
||||
});
|
||||
if (!resolved) return null;
|
||||
if (resolved.source !== "profile") await setVeniceApiKey(resolved.key);
|
||||
nextConfig = applyAuthProfileConfig(nextConfig, {
|
||||
profileId: "venice:default",
|
||||
provider: "venice",
|
||||
mode: "api_key",
|
||||
});
|
||||
return applyVeniceConfig(nextConfig);
|
||||
}
|
||||
|
||||
if (
|
||||
authChoice === "minimax-cloud" ||
|
||||
|
||||
@@ -211,6 +211,11 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
|
||||
parseJson: (raw) => deps.json5.parse(raw),
|
||||
});
|
||||
|
||||
// Apply config.env to process.env BEFORE substitution so ${VAR} can reference config-defined vars
|
||||
if (resolved && typeof resolved === "object" && "env" in resolved) {
|
||||
applyConfigEnv(resolved as ClawdbotConfig, deps.env);
|
||||
}
|
||||
|
||||
// Substitute ${VAR} env var references
|
||||
const substituted = resolveConfigEnvVars(resolved, deps.env);
|
||||
|
||||
@@ -365,6 +370,11 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
// Apply config.env to process.env BEFORE substitution so ${VAR} can reference config-defined vars
|
||||
if (resolved && typeof resolved === "object" && "env" in resolved) {
|
||||
applyConfigEnv(resolved as ClawdbotConfig, deps.env);
|
||||
}
|
||||
|
||||
// Substitute ${VAR} env var references
|
||||
let substituted: unknown;
|
||||
try {
|
||||
|
||||
@@ -35,7 +35,7 @@ export const ChatHistoryParamsSchema = Type.Object(
|
||||
export const ChatSendParamsSchema = Type.Object(
|
||||
{
|
||||
sessionKey: NonEmptyString,
|
||||
message: NonEmptyString,
|
||||
message: Type.String(),
|
||||
thinking: Type.Optional(Type.String()),
|
||||
deliver: Type.Optional(Type.Boolean()),
|
||||
attachments: Type.Optional(Type.Array(Type.Unknown())),
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { createAgentEventHandler, createChatRunState } from "./server-chat.js";
|
||||
|
||||
describe("agent event handler", () => {
|
||||
it("emits chat delta for assistant text-only events", () => {
|
||||
const nowSpy = vi.spyOn(Date, "now").mockReturnValue(1_000);
|
||||
const broadcast = vi.fn();
|
||||
const nodeSendToSession = vi.fn();
|
||||
const agentRunSeq = new Map<string, number>();
|
||||
const chatRunState = createChatRunState();
|
||||
chatRunState.registry.add("run-1", { sessionKey: "session-1", clientRunId: "client-1" });
|
||||
|
||||
const handler = createAgentEventHandler({
|
||||
broadcast,
|
||||
nodeSendToSession,
|
||||
agentRunSeq,
|
||||
chatRunState,
|
||||
resolveSessionKeyForRun: () => undefined,
|
||||
clearAgentRunContext: vi.fn(),
|
||||
});
|
||||
|
||||
handler({
|
||||
runId: "run-1",
|
||||
seq: 1,
|
||||
stream: "assistant",
|
||||
ts: Date.now(),
|
||||
data: { text: "Hello world" },
|
||||
});
|
||||
|
||||
const chatCalls = broadcast.mock.calls.filter(([event]) => event === "chat");
|
||||
expect(chatCalls).toHaveLength(1);
|
||||
const payload = chatCalls[0]?.[1] as {
|
||||
state?: string;
|
||||
message?: { content?: Array<{ text?: string }> };
|
||||
};
|
||||
expect(payload.state).toBe("delta");
|
||||
expect(payload.message?.content?.[0]?.text).toBe("Hello world");
|
||||
const sessionChatCalls = nodeSendToSession.mock.calls.filter(([, event]) => event === "chat");
|
||||
expect(sessionChatCalls).toHaveLength(1);
|
||||
nowSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,163 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { GatewayRequestContext } from "./types.js";
|
||||
import { agentHandlers } from "./agent.js";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
loadSessionEntry: vi.fn(),
|
||||
updateSessionStore: vi.fn(),
|
||||
agentCommand: vi.fn(),
|
||||
registerAgentRunContext: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../session-utils.js", () => ({
|
||||
loadSessionEntry: mocks.loadSessionEntry,
|
||||
}));
|
||||
|
||||
vi.mock("../../config/sessions.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../../config/sessions.js")>(
|
||||
"../../config/sessions.js",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
updateSessionStore: mocks.updateSessionStore,
|
||||
resolveAgentIdFromSessionKey: () => "main",
|
||||
resolveExplicitAgentSessionKey: () => undefined,
|
||||
resolveAgentMainSessionKey: () => "agent:main:main",
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../commands/agent.js", () => ({
|
||||
agentCommand: mocks.agentCommand,
|
||||
}));
|
||||
|
||||
vi.mock("../../config/config.js", () => ({
|
||||
loadConfig: () => ({}),
|
||||
}));
|
||||
|
||||
vi.mock("../../agents/agent-scope.js", () => ({
|
||||
listAgentIds: () => ["main"],
|
||||
}));
|
||||
|
||||
vi.mock("../../infra/agent-events.js", () => ({
|
||||
registerAgentRunContext: mocks.registerAgentRunContext,
|
||||
onAgentEvent: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../sessions/send-policy.js", () => ({
|
||||
resolveSendPolicy: () => "allow",
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/delivery-context.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../../utils/delivery-context.js")>(
|
||||
"../../utils/delivery-context.js",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
normalizeSessionDeliveryFields: () => ({}),
|
||||
};
|
||||
});
|
||||
|
||||
const makeContext = (): GatewayRequestContext =>
|
||||
({
|
||||
dedupe: new Map(),
|
||||
addChatRun: vi.fn(),
|
||||
logGateway: { info: vi.fn(), error: vi.fn() },
|
||||
}) as unknown as GatewayRequestContext;
|
||||
|
||||
describe("gateway agent handler", () => {
|
||||
it("preserves cliSessionIds from existing session entry", async () => {
|
||||
const existingCliSessionIds = { "claude-cli": "abc-123-def" };
|
||||
const existingClaudeCliSessionId = "abc-123-def";
|
||||
|
||||
mocks.loadSessionEntry.mockReturnValue({
|
||||
cfg: {},
|
||||
storePath: "/tmp/sessions.json",
|
||||
entry: {
|
||||
sessionId: "existing-session-id",
|
||||
updatedAt: Date.now(),
|
||||
cliSessionIds: existingCliSessionIds,
|
||||
claudeCliSessionId: existingClaudeCliSessionId,
|
||||
},
|
||||
canonicalKey: "agent:main:main",
|
||||
});
|
||||
|
||||
let capturedEntry: Record<string, unknown> | undefined;
|
||||
mocks.updateSessionStore.mockImplementation(async (_path, updater) => {
|
||||
const store: Record<string, unknown> = {};
|
||||
await updater(store);
|
||||
capturedEntry = store["agent:main:main"] as Record<string, unknown>;
|
||||
});
|
||||
|
||||
mocks.agentCommand.mockResolvedValue({
|
||||
payloads: [{ text: "ok" }],
|
||||
meta: { durationMs: 100 },
|
||||
});
|
||||
|
||||
const respond = vi.fn();
|
||||
await agentHandlers.agent({
|
||||
params: {
|
||||
message: "test",
|
||||
agentId: "main",
|
||||
sessionKey: "agent:main:main",
|
||||
idempotencyKey: "test-idem",
|
||||
},
|
||||
respond,
|
||||
context: makeContext(),
|
||||
req: { type: "req", id: "1", method: "agent" },
|
||||
client: null,
|
||||
isWebchatConnect: () => false,
|
||||
});
|
||||
|
||||
expect(mocks.updateSessionStore).toHaveBeenCalled();
|
||||
expect(capturedEntry).toBeDefined();
|
||||
expect(capturedEntry?.cliSessionIds).toEqual(existingCliSessionIds);
|
||||
expect(capturedEntry?.claudeCliSessionId).toBe(existingClaudeCliSessionId);
|
||||
});
|
||||
|
||||
it("handles missing cliSessionIds gracefully", async () => {
|
||||
mocks.loadSessionEntry.mockReturnValue({
|
||||
cfg: {},
|
||||
storePath: "/tmp/sessions.json",
|
||||
entry: {
|
||||
sessionId: "existing-session-id",
|
||||
updatedAt: Date.now(),
|
||||
// No cliSessionIds or claudeCliSessionId
|
||||
},
|
||||
canonicalKey: "agent:main:main",
|
||||
});
|
||||
|
||||
let capturedEntry: Record<string, unknown> | undefined;
|
||||
mocks.updateSessionStore.mockImplementation(async (_path, updater) => {
|
||||
const store: Record<string, unknown> = {};
|
||||
await updater(store);
|
||||
capturedEntry = store["agent:main:main"] as Record<string, unknown>;
|
||||
});
|
||||
|
||||
mocks.agentCommand.mockResolvedValue({
|
||||
payloads: [{ text: "ok" }],
|
||||
meta: { durationMs: 100 },
|
||||
});
|
||||
|
||||
const respond = vi.fn();
|
||||
await agentHandlers.agent({
|
||||
params: {
|
||||
message: "test",
|
||||
agentId: "main",
|
||||
sessionKey: "agent:main:main",
|
||||
idempotencyKey: "test-idem-2",
|
||||
},
|
||||
respond,
|
||||
context: makeContext(),
|
||||
req: { type: "req", id: "2", method: "agent" },
|
||||
client: null,
|
||||
isWebchatConnect: () => false,
|
||||
});
|
||||
|
||||
expect(mocks.updateSessionStore).toHaveBeenCalled();
|
||||
expect(capturedEntry).toBeDefined();
|
||||
// Should be undefined, not cause an error
|
||||
expect(capturedEntry?.cliSessionIds).toBeUndefined();
|
||||
expect(capturedEntry?.claudeCliSessionId).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -251,6 +251,8 @@ export const agentHandlers: GatewayRequestHandlers = {
|
||||
groupId: resolvedGroupId ?? entry?.groupId,
|
||||
groupChannel: resolvedGroupChannel ?? entry?.groupChannel,
|
||||
space: resolvedGroupSpace ?? entry?.space,
|
||||
cliSessionIds: entry?.cliSessionIds,
|
||||
claudeCliSessionId: entry?.claudeCliSessionId,
|
||||
};
|
||||
sessionEntry = nextEntry;
|
||||
const sendPolicy = resolveSendPolicy({
|
||||
|
||||
@@ -338,6 +338,15 @@ export const chatHandlers: GatewayRequestHandlers = {
|
||||
: undefined,
|
||||
}))
|
||||
.filter((a) => a.content) ?? [];
|
||||
const rawMessage = p.message.trim();
|
||||
if (!rawMessage && normalizedAttachments.length === 0) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(ErrorCodes.INVALID_REQUEST, "message or attachment required"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
let parsedMessage = p.message;
|
||||
let parsedImages: ChatImageContent[] = [];
|
||||
if (normalizedAttachments.length > 0) {
|
||||
|
||||
@@ -28,11 +28,16 @@ export async function scheduleRestartSentinelWake(params: { deps: CliDeps }) {
|
||||
return;
|
||||
}
|
||||
|
||||
const threadMarker = ":thread:";
|
||||
const threadIndex = sessionKey.lastIndexOf(threadMarker);
|
||||
const baseSessionKey = threadIndex === -1 ? sessionKey : sessionKey.slice(0, threadIndex);
|
||||
// Extract topic/thread ID from sessionKey (supports both :topic: and :thread:)
|
||||
// Telegram uses :topic:, other platforms use :thread:
|
||||
const topicIndex = sessionKey.lastIndexOf(":topic:");
|
||||
const threadIndex = sessionKey.lastIndexOf(":thread:");
|
||||
const markerIndex = Math.max(topicIndex, threadIndex);
|
||||
const marker = topicIndex > threadIndex ? ":topic:" : ":thread:";
|
||||
|
||||
const baseSessionKey = markerIndex === -1 ? sessionKey : sessionKey.slice(0, markerIndex);
|
||||
const threadIdRaw =
|
||||
threadIndex === -1 ? undefined : sessionKey.slice(threadIndex + threadMarker.length);
|
||||
markerIndex === -1 ? undefined : sessionKey.slice(markerIndex + marker.length);
|
||||
const sessionThreadId = threadIdRaw?.trim() || undefined;
|
||||
|
||||
const { cfg, entry } = loadSessionEntry(sessionKey);
|
||||
@@ -42,7 +47,7 @@ export async function scheduleRestartSentinelWake(params: { deps: CliDeps }) {
|
||||
// Handles race condition where store wasn't flushed before restart
|
||||
const sentinelContext = payload.deliveryContext;
|
||||
let sessionDeliveryContext = deliveryContextFromSession(entry);
|
||||
if (!sessionDeliveryContext && threadIndex !== -1 && baseSessionKey) {
|
||||
if (!sessionDeliveryContext && markerIndex !== -1 && baseSessionKey) {
|
||||
const { entry: baseEntry } = loadSessionEntry(baseSessionKey);
|
||||
sessionDeliveryContext = deliveryContextFromSession(baseEntry);
|
||||
}
|
||||
@@ -74,6 +79,7 @@ export async function scheduleRestartSentinelWake(params: { deps: CliDeps }) {
|
||||
|
||||
const threadId =
|
||||
payload.threadId ??
|
||||
parsedTarget?.threadId ?? // From resolveAnnounceTargetFromKey (extracts :topic:N)
|
||||
sessionThreadId ??
|
||||
(origin?.threadId != null ? String(origin.threadId) : undefined);
|
||||
|
||||
|
||||
@@ -208,6 +208,39 @@ describe("gateway server chat", () => {
|
||||
| undefined;
|
||||
expect(imgOpts?.images).toEqual([{ type: "image", data: pngB64, mimeType: "image/png" }]);
|
||||
|
||||
const callsBeforeImageOnly = spy.mock.calls.length;
|
||||
const reqIdOnly = "chat-img-only";
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "req",
|
||||
id: reqIdOnly,
|
||||
method: "chat.send",
|
||||
params: {
|
||||
sessionKey: "main",
|
||||
message: "",
|
||||
idempotencyKey: "idem-img-only",
|
||||
attachments: [
|
||||
{
|
||||
type: "image",
|
||||
mimeType: "image/png",
|
||||
fileName: "dot.png",
|
||||
content: `data:image/png;base64,${pngB64}`,
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const imgOnlyRes = await onceMessage(ws, (o) => o.type === "res" && o.id === reqIdOnly, 8000);
|
||||
expect(imgOnlyRes.ok).toBe(true);
|
||||
expect(imgOnlyRes.payload?.runId).toBeDefined();
|
||||
|
||||
await waitFor(() => spy.mock.calls.length > callsBeforeImageOnly, 8000);
|
||||
const imgOnlyOpts = spy.mock.calls.at(-1)?.[1] as
|
||||
| { images?: Array<{ type: string; data: string; mimeType: string }> }
|
||||
| undefined;
|
||||
expect(imgOnlyOpts?.images).toEqual([{ type: "image", data: pngB64, mimeType: "image/png" }]);
|
||||
|
||||
const historyDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
|
||||
tempDirs.push(historyDir);
|
||||
testState.sessionStorePath = path.join(historyDir, "sessions.json");
|
||||
|
||||
@@ -381,6 +381,31 @@ export function resolveGatewaySessionStoreTarget(params: { cfg: ClawdbotConfig;
|
||||
};
|
||||
}
|
||||
|
||||
// Merge with existing entry based on latest timestamp to ensure data consistency and avoid overwriting with less complete data.
|
||||
function mergeSessionEntryIntoCombined(params: {
|
||||
combined: Record<string, SessionEntry>;
|
||||
entry: SessionEntry;
|
||||
agentId: string;
|
||||
canonicalKey: string;
|
||||
}) {
|
||||
const { combined, entry, agentId, canonicalKey } = params;
|
||||
const existing = combined[canonicalKey];
|
||||
|
||||
if (existing && (existing.updatedAt ?? 0) > (entry.updatedAt ?? 0)) {
|
||||
combined[canonicalKey] = {
|
||||
...entry,
|
||||
...existing,
|
||||
spawnedBy: canonicalizeSpawnedByForAgent(agentId, existing.spawnedBy ?? entry.spawnedBy),
|
||||
};
|
||||
} else {
|
||||
combined[canonicalKey] = {
|
||||
...existing,
|
||||
...entry,
|
||||
spawnedBy: canonicalizeSpawnedByForAgent(agentId, entry.spawnedBy ?? existing?.spawnedBy),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function loadCombinedSessionStoreForGateway(cfg: ClawdbotConfig): {
|
||||
storePath: string;
|
||||
store: Record<string, SessionEntry>;
|
||||
@@ -393,10 +418,12 @@ export function loadCombinedSessionStoreForGateway(cfg: ClawdbotConfig): {
|
||||
const combined: Record<string, SessionEntry> = {};
|
||||
for (const [key, entry] of Object.entries(store)) {
|
||||
const canonicalKey = canonicalizeSessionKeyForAgent(defaultAgentId, key);
|
||||
combined[canonicalKey] = {
|
||||
...entry,
|
||||
spawnedBy: canonicalizeSpawnedByForAgent(defaultAgentId, entry.spawnedBy),
|
||||
};
|
||||
mergeSessionEntryIntoCombined({
|
||||
combined,
|
||||
entry,
|
||||
agentId: defaultAgentId,
|
||||
canonicalKey,
|
||||
});
|
||||
}
|
||||
return { storePath, store: combined };
|
||||
}
|
||||
@@ -408,13 +435,12 @@ export function loadCombinedSessionStoreForGateway(cfg: ClawdbotConfig): {
|
||||
const store = loadSessionStore(storePath);
|
||||
for (const [key, entry] of Object.entries(store)) {
|
||||
const canonicalKey = canonicalizeSessionKeyForAgent(agentId, key);
|
||||
// Merge with existing entry if present (avoid overwriting with less complete data)
|
||||
const existing = combined[canonicalKey];
|
||||
combined[canonicalKey] = {
|
||||
...existing,
|
||||
...entry,
|
||||
spawnedBy: canonicalizeSpawnedByForAgent(agentId, entry.spawnedBy ?? existing?.spawnedBy),
|
||||
};
|
||||
mergeSessionEntryIntoCombined({
|
||||
combined,
|
||||
entry,
|
||||
agentId,
|
||||
canonicalKey,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -129,9 +129,10 @@ export async function checkGitUpdateStatus(params: {
|
||||
).catch(() => null);
|
||||
const upstream = upstreamRes && upstreamRes.code === 0 ? upstreamRes.stdout.trim() : null;
|
||||
|
||||
const dirtyRes = await runCommandWithTimeout(["git", "-C", root, "status", "--porcelain"], {
|
||||
timeoutMs,
|
||||
}).catch(() => null);
|
||||
const dirtyRes = await runCommandWithTimeout(
|
||||
["git", "-C", root, "status", "--porcelain", "--", ":!dist/control-ui/"],
|
||||
{ timeoutMs },
|
||||
).catch(() => null);
|
||||
const dirty = dirtyRes && dirtyRes.code === 0 ? dirtyRes.stdout.trim().length > 0 : null;
|
||||
|
||||
const fetchOk = params.fetch
|
||||
|
||||
@@ -44,7 +44,7 @@ describe("runGatewayUpdate", () => {
|
||||
[`git -C ${tempDir} rev-parse --show-toplevel`]: { stdout: tempDir },
|
||||
[`git -C ${tempDir} rev-parse HEAD`]: { stdout: "abc123" },
|
||||
[`git -C ${tempDir} rev-parse --abbrev-ref HEAD`]: { stdout: "main" },
|
||||
[`git -C ${tempDir} status --porcelain`]: { stdout: " M README.md" },
|
||||
[`git -C ${tempDir} status --porcelain -- :!dist/control-ui/`]: { stdout: " M README.md" },
|
||||
});
|
||||
|
||||
const result = await runGatewayUpdate({
|
||||
@@ -69,7 +69,7 @@ describe("runGatewayUpdate", () => {
|
||||
[`git -C ${tempDir} rev-parse --show-toplevel`]: { stdout: tempDir },
|
||||
[`git -C ${tempDir} rev-parse HEAD`]: { stdout: "abc123" },
|
||||
[`git -C ${tempDir} rev-parse --abbrev-ref HEAD`]: { stdout: "main" },
|
||||
[`git -C ${tempDir} status --porcelain`]: { stdout: "" },
|
||||
[`git -C ${tempDir} status --porcelain -- :!dist/control-ui/`]: { stdout: "" },
|
||||
[`git -C ${tempDir} rev-parse --abbrev-ref --symbolic-full-name @{upstream}`]: {
|
||||
stdout: "origin/main",
|
||||
},
|
||||
@@ -103,7 +103,7 @@ describe("runGatewayUpdate", () => {
|
||||
const { runner, calls } = createRunner({
|
||||
[`git -C ${tempDir} rev-parse --show-toplevel`]: { stdout: tempDir },
|
||||
[`git -C ${tempDir} rev-parse HEAD`]: { stdout: "abc123" },
|
||||
[`git -C ${tempDir} status --porcelain`]: { stdout: "" },
|
||||
[`git -C ${tempDir} status --porcelain -- :!dist/control-ui/`]: { stdout: "" },
|
||||
[`git -C ${tempDir} fetch --all --prune --tags`]: { stdout: "" },
|
||||
[`git -C ${tempDir} tag --list v* --sort=-v:refname`]: {
|
||||
stdout: `${stableTag}\n${betaTag}\n`,
|
||||
@@ -112,6 +112,7 @@ describe("runGatewayUpdate", () => {
|
||||
"pnpm install": { stdout: "" },
|
||||
"pnpm build": { stdout: "" },
|
||||
"pnpm ui:build": { stdout: "" },
|
||||
[`git -C ${tempDir} checkout -- dist/control-ui/`]: { stdout: "" },
|
||||
"pnpm clawdbot doctor --non-interactive": { stdout: "" },
|
||||
});
|
||||
|
||||
|
||||
@@ -346,10 +346,14 @@ export async function runGatewayUpdate(opts: UpdateRunnerOptions = {}): Promise<
|
||||
const channel: UpdateChannel = opts.channel ?? "dev";
|
||||
const branch = channel === "dev" ? await readBranchName(runCommand, gitRoot, timeoutMs) : null;
|
||||
const needsCheckoutMain = channel === "dev" && branch !== DEV_BRANCH;
|
||||
gitTotalSteps = channel === "dev" ? (needsCheckoutMain ? 10 : 9) : 8;
|
||||
gitTotalSteps = channel === "dev" ? (needsCheckoutMain ? 11 : 10) : 9;
|
||||
|
||||
const statusCheck = await runStep(
|
||||
step("clean check", ["git", "-C", gitRoot, "status", "--porcelain"], gitRoot),
|
||||
step(
|
||||
"clean check",
|
||||
["git", "-C", gitRoot, "status", "--porcelain", "--", ":!dist/control-ui/"],
|
||||
gitRoot,
|
||||
),
|
||||
);
|
||||
steps.push(statusCheck);
|
||||
const hasUncommittedChanges =
|
||||
@@ -654,6 +658,17 @@ export async function runGatewayUpdate(opts: UpdateRunnerOptions = {}): Promise<
|
||||
);
|
||||
steps.push(uiBuildStep);
|
||||
|
||||
// Restore dist/control-ui/ to committed state to prevent dirty repo after update
|
||||
// (ui:build regenerates assets with new hashes, which would block future updates)
|
||||
const restoreUiStep = await runStep(
|
||||
step(
|
||||
"restore control-ui",
|
||||
["git", "-C", gitRoot, "checkout", "--", "dist/control-ui/"],
|
||||
gitRoot,
|
||||
),
|
||||
);
|
||||
steps.push(restoreUiStep);
|
||||
|
||||
const doctorStep = await runStep(
|
||||
step(
|
||||
"clawdbot doctor",
|
||||
|
||||
+18
-12
@@ -11,6 +11,12 @@ export const DEFAULT_AGENT_ID = "main";
|
||||
export const DEFAULT_MAIN_KEY = "main";
|
||||
export const DEFAULT_ACCOUNT_ID = "default";
|
||||
|
||||
// Pre-compiled regex
|
||||
const VALID_ID_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/i;
|
||||
const INVALID_CHARS_RE = /[^a-z0-9_-]+/g;
|
||||
const LEADING_DASH_RE = /^-+/;
|
||||
const TRAILING_DASH_RE = /-+$/;
|
||||
|
||||
function normalizeToken(value: string | undefined | null): string {
|
||||
return (value ?? "").trim().toLowerCase();
|
||||
}
|
||||
@@ -52,14 +58,14 @@ export function normalizeAgentId(value: string | undefined | null): string {
|
||||
const trimmed = (value ?? "").trim();
|
||||
if (!trimmed) return DEFAULT_AGENT_ID;
|
||||
// Keep it path-safe + shell-friendly.
|
||||
if (/^[a-z0-9][a-z0-9_-]{0,63}$/i.test(trimmed)) return trimmed.toLowerCase();
|
||||
if (VALID_ID_RE.test(trimmed)) return trimmed.toLowerCase();
|
||||
// Best-effort fallback: collapse invalid characters to "-"
|
||||
return (
|
||||
trimmed
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9_-]+/g, "-")
|
||||
.replace(/^-+/, "")
|
||||
.replace(/-+$/, "")
|
||||
.replace(INVALID_CHARS_RE, "-")
|
||||
.replace(LEADING_DASH_RE, "")
|
||||
.replace(TRAILING_DASH_RE, "")
|
||||
.slice(0, 64) || DEFAULT_AGENT_ID
|
||||
);
|
||||
}
|
||||
@@ -67,13 +73,13 @@ export function normalizeAgentId(value: string | undefined | null): string {
|
||||
export function sanitizeAgentId(value: string | undefined | null): string {
|
||||
const trimmed = (value ?? "").trim();
|
||||
if (!trimmed) return DEFAULT_AGENT_ID;
|
||||
if (/^[a-z0-9][a-z0-9_-]{0,63}$/i.test(trimmed)) return trimmed.toLowerCase();
|
||||
if (VALID_ID_RE.test(trimmed)) return trimmed.toLowerCase();
|
||||
return (
|
||||
trimmed
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9_-]+/gi, "-")
|
||||
.replace(/^-+/, "")
|
||||
.replace(/-+$/, "")
|
||||
.replace(INVALID_CHARS_RE, "-")
|
||||
.replace(LEADING_DASH_RE, "")
|
||||
.replace(TRAILING_DASH_RE, "")
|
||||
.slice(0, 64) || DEFAULT_AGENT_ID
|
||||
);
|
||||
}
|
||||
@@ -81,13 +87,13 @@ export function sanitizeAgentId(value: string | undefined | null): string {
|
||||
export function normalizeAccountId(value: string | undefined | null): string {
|
||||
const trimmed = (value ?? "").trim();
|
||||
if (!trimmed) return DEFAULT_ACCOUNT_ID;
|
||||
if (/^[a-z0-9][a-z0-9_-]{0,63}$/i.test(trimmed)) return trimmed.toLowerCase();
|
||||
if (VALID_ID_RE.test(trimmed)) return trimmed.toLowerCase();
|
||||
return (
|
||||
trimmed
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9_-]+/g, "-")
|
||||
.replace(/^-+/, "")
|
||||
.replace(/-+$/, "")
|
||||
.replace(INVALID_CHARS_RE, "-")
|
||||
.replace(LEADING_DASH_RE, "")
|
||||
.replace(TRAILING_DASH_RE, "")
|
||||
.slice(0, 64) || DEFAULT_ACCOUNT_ID
|
||||
);
|
||||
}
|
||||
|
||||
@@ -141,7 +141,9 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
|
||||
});
|
||||
markDispatchIdle();
|
||||
|
||||
if (!queuedFinal) {
|
||||
const anyReplyDelivered = queuedFinal || (counts.block ?? 0) > 0 || (counts.final ?? 0) > 0;
|
||||
|
||||
if (!anyReplyDelivered) {
|
||||
if (prepared.isRoomish) {
|
||||
clearHistoryEntriesIfEnabled({
|
||||
historyMap: ctx.channelHistories,
|
||||
|
||||
@@ -69,7 +69,7 @@ export class FilterableSelectList implements Component {
|
||||
lines.push(filterLabel + inputText);
|
||||
|
||||
// Separator
|
||||
lines.push(chalk.dim("─".repeat(width)));
|
||||
lines.push(chalk.dim("─".repeat(Math.max(0, width))));
|
||||
|
||||
// Select list
|
||||
const listLines = this.selectList.render(width);
|
||||
|
||||
@@ -214,7 +214,8 @@ export class SearchableSelectList implements Component {
|
||||
const maxValueWidth = Math.min(30, width - prefixWidth - 4);
|
||||
const truncatedValue = truncateToWidth(displayValue, maxValueWidth, "");
|
||||
const valueText = this.highlightMatch(truncatedValue, query);
|
||||
const spacing = " ".repeat(Math.max(1, 32 - visibleWidth(valueText)));
|
||||
const spacingWidth = Math.max(1, 32 - visibleWidth(valueText));
|
||||
const spacing = " ".repeat(spacingWidth);
|
||||
const descriptionStart = prefixWidth + visibleWidth(valueText) + spacing.length;
|
||||
const remainingWidth = width - descriptionStart - 2;
|
||||
if (remainingWidth > 10) {
|
||||
|
||||
Reference in new issue
Block a user