mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-13 01:31:48 +08:00
Compare commits
2 Commits
fix/export
...
reminders
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1e42399b9c | ||
|
|
bc44453d36 |
@@ -13,18 +13,13 @@ Docs: https://docs.clawd.bot
|
||||
- Signal: add typing indicators and DM read receipts via signal-cli.
|
||||
- MSTeams: add file uploads, adaptive cards, and attachment handling improvements. (#1410) Thanks @Evizero.
|
||||
|
||||
### Breaking
|
||||
- **BREAKING:** Envelope and system event timestamps now default to host-local time (was UTC) so agents don’t have to constantly convert.
|
||||
|
||||
### Fixes
|
||||
- Config: avoid stack traces for invalid configs and log the config path.
|
||||
- Doctor: warn when gateway.mode is unset with configure/config guidance.
|
||||
- OpenCode Zen: route models to the Zen API shape per family so proxy endpoints are used. (#1416)
|
||||
- macOS: include Textual syntax highlighting resources in packaged app to prevent chat crashes. (#1362)
|
||||
- Cron: cap reminder context history to 10 messages and honor `contextMessages`. (#1103) Thanks @mkbehr.
|
||||
- Exec approvals: treat main as the default agent + migrate legacy default allowlists. (#1417) Thanks @czekaj.
|
||||
- Cron: clarify reminder systemEvent wording guidance. (#1204) Thanks @cpojer.
|
||||
- UI: refresh debug panel on route-driven tab changes. (#1373) Thanks @yazinsai.
|
||||
- UI: export config form section metadata for shared usage. (#1418) Thanks @MaudeBot.
|
||||
|
||||
## 2026.1.21
|
||||
|
||||
|
||||
@@ -149,7 +149,6 @@ struct ExecApprovalsResolvedDefaults {
|
||||
|
||||
enum ExecApprovalsStore {
|
||||
private static let logger = Logger(subsystem: "com.clawdbot", category: "exec-approvals")
|
||||
private static let defaultAgentId = "main"
|
||||
private static let defaultSecurity: ExecSecurity = .deny
|
||||
private static let defaultAsk: ExecAsk = .onMiss
|
||||
private static let defaultAskFallback: ExecSecurity = .deny
|
||||
@@ -166,22 +165,13 @@ enum ExecApprovalsStore {
|
||||
static func normalizeIncoming(_ file: ExecApprovalsFile) -> ExecApprovalsFile {
|
||||
let socketPath = file.socket?.path?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
let token = file.socket?.token?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
var agents = file.agents ?? [:]
|
||||
if let legacyDefault = agents["default"] {
|
||||
if let main = agents[self.defaultAgentId] {
|
||||
agents[self.defaultAgentId] = self.mergeAgents(current: main, legacy: legacyDefault)
|
||||
} else {
|
||||
agents[self.defaultAgentId] = legacyDefault
|
||||
}
|
||||
agents.removeValue(forKey: "default")
|
||||
}
|
||||
return ExecApprovalsFile(
|
||||
version: 1,
|
||||
socket: ExecApprovalsSocketConfig(
|
||||
path: socketPath.isEmpty ? nil : socketPath,
|
||||
token: token.isEmpty ? nil : token),
|
||||
defaults: file.defaults,
|
||||
agents: agents)
|
||||
agents: file.agents)
|
||||
}
|
||||
|
||||
static func readSnapshot() -> ExecApprovalsSnapshot {
|
||||
@@ -282,7 +272,9 @@ enum ExecApprovalsStore {
|
||||
ask: defaults.ask ?? self.defaultAsk,
|
||||
askFallback: defaults.askFallback ?? self.defaultAskFallback,
|
||||
autoAllowSkills: defaults.autoAllowSkills ?? self.defaultAutoAllowSkills)
|
||||
let key = self.agentKey(agentId)
|
||||
let key = (agentId?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false)
|
||||
? agentId!.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
: "default"
|
||||
let agentEntry = file.agents?[key] ?? ExecApprovalsAgent()
|
||||
let wildcardEntry = file.agents?["*"] ?? ExecApprovalsAgent()
|
||||
let resolvedAgent = ExecApprovalsResolvedDefaults(
|
||||
@@ -465,36 +457,7 @@ enum ExecApprovalsStore {
|
||||
|
||||
private static func agentKey(_ agentId: String?) -> String {
|
||||
let trimmed = agentId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
return trimmed.isEmpty ? self.defaultAgentId : trimmed
|
||||
}
|
||||
|
||||
private static func normalizedPattern(_ pattern: String?) -> String? {
|
||||
let trimmed = pattern?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
return trimmed.isEmpty ? nil : trimmed.lowercased()
|
||||
}
|
||||
|
||||
private static func mergeAgents(
|
||||
current: ExecApprovalsAgent,
|
||||
legacy: ExecApprovalsAgent
|
||||
) -> ExecApprovalsAgent {
|
||||
var seen = Set<String>()
|
||||
var allowlist: [ExecAllowlistEntry] = []
|
||||
func append(_ entry: ExecAllowlistEntry) {
|
||||
guard let key = self.normalizedPattern(entry.pattern), !seen.contains(key) else {
|
||||
return
|
||||
}
|
||||
seen.insert(key)
|
||||
allowlist.append(entry)
|
||||
}
|
||||
for entry in current.allowlist ?? [] { append(entry) }
|
||||
for entry in legacy.allowlist ?? [] { append(entry) }
|
||||
|
||||
return ExecApprovalsAgent(
|
||||
security: current.security ?? legacy.security,
|
||||
ask: current.ask ?? legacy.ask,
|
||||
askFallback: current.askFallback ?? legacy.askFallback,
|
||||
autoAllowSkills: current.autoAllowSkills ?? legacy.autoAllowSkills,
|
||||
allowlist: allowlist.isEmpty ? nil : allowlist)
|
||||
return trimmed.isEmpty ? "default" : trimmed
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,15 +9,15 @@ read_when:
|
||||
|
||||
Clawdbot standardizes timestamps so the model sees a **single reference time**.
|
||||
|
||||
## Message envelopes (local by default)
|
||||
## Message envelopes (UTC by default)
|
||||
|
||||
Inbound messages are wrapped in an envelope like:
|
||||
|
||||
```
|
||||
[Provider ... 2026-01-05 16:26 PST] message text
|
||||
[Provider ... 2026-01-05T21:26Z] message text
|
||||
```
|
||||
|
||||
The timestamp in the envelope is **host-local by default**, with minutes precision.
|
||||
The timestamp in the envelope is **UTC by default**, with minutes precision.
|
||||
|
||||
You can override this with:
|
||||
|
||||
@@ -25,7 +25,7 @@ You can override this with:
|
||||
{
|
||||
agents: {
|
||||
defaults: {
|
||||
envelopeTimezone: "local", // "utc" | "local" | "user" | IANA timezone
|
||||
envelopeTimezone: "user", // "utc" | "local" | "user" | IANA timezone
|
||||
envelopeTimestamp: "on", // "on" | "off"
|
||||
envelopeElapsed: "on" // "on" | "off"
|
||||
}
|
||||
@@ -33,7 +33,6 @@ You can override this with:
|
||||
}
|
||||
```
|
||||
|
||||
- `envelopeTimezone: "utc"` uses UTC.
|
||||
- `envelopeTimezone: "user"` uses `agents.defaults.userTimezone` (falls back to host timezone).
|
||||
- Use an explicit IANA timezone (e.g., `"Europe/Vienna"`) for a fixed offset.
|
||||
- `envelopeTimestamp: "off"` removes absolute timestamps from envelope headers.
|
||||
@@ -41,10 +40,10 @@ You can override this with:
|
||||
|
||||
### Examples
|
||||
|
||||
**Local (default):**
|
||||
**UTC (default):**
|
||||
|
||||
```
|
||||
[Signal Alice +1555 2026-01-18 00:19 PST] hello
|
||||
[Signal Alice +1555 2026-01-18T05:19Z] hello
|
||||
```
|
||||
|
||||
**Fixed timezone:**
|
||||
|
||||
@@ -7,18 +7,18 @@ read_when:
|
||||
|
||||
# Date & Time
|
||||
|
||||
Clawdbot defaults to **host-local time for transport timestamps** and **user-local time only in the system prompt**.
|
||||
Clawdbot defaults to **UTC for transport timestamps** and **user-local time only in the system prompt**.
|
||||
Provider timestamps are preserved so tools keep their native semantics.
|
||||
|
||||
## Message envelopes (local by default)
|
||||
## Message envelopes (UTC by default)
|
||||
|
||||
Inbound messages are wrapped with a timestamp (minute precision):
|
||||
Inbound messages are wrapped with a UTC timestamp (minute precision):
|
||||
|
||||
```
|
||||
[Provider ... 2026-01-05 16:26 PST] message text
|
||||
[Provider ... 2026-01-05T21:26Z] message text
|
||||
```
|
||||
|
||||
This envelope timestamp is **host-local by default**, regardless of the provider timezone.
|
||||
This envelope timestamp is **UTC by default**, regardless of the host timezone.
|
||||
|
||||
You can override this behavior:
|
||||
|
||||
@@ -26,7 +26,7 @@ You can override this behavior:
|
||||
{
|
||||
agents: {
|
||||
defaults: {
|
||||
envelopeTimezone: "local", // "utc" | "local" | "user" | IANA timezone
|
||||
envelopeTimezone: "utc", // "utc" | "local" | "user" | IANA timezone
|
||||
envelopeTimestamp: "on", // "on" | "off"
|
||||
envelopeElapsed: "on" // "on" | "off"
|
||||
}
|
||||
@@ -34,7 +34,6 @@ You can override this behavior:
|
||||
}
|
||||
```
|
||||
|
||||
- `envelopeTimezone: "utc"` uses UTC.
|
||||
- `envelopeTimezone: "local"` uses the host timezone.
|
||||
- `envelopeTimezone: "user"` uses `agents.defaults.userTimezone` (falls back to host timezone).
|
||||
- Use an explicit IANA timezone (e.g., `"America/Chicago"`) for a fixed zone.
|
||||
@@ -43,10 +42,10 @@ You can override this behavior:
|
||||
|
||||
### Examples
|
||||
|
||||
**Local (default):**
|
||||
**UTC (default):**
|
||||
|
||||
```
|
||||
[WhatsApp +1555 2026-01-18 00:19 PST] hello
|
||||
[WhatsApp +1555 2026-01-18T05:19Z] hello
|
||||
```
|
||||
|
||||
**User timezone:**
|
||||
@@ -74,13 +73,12 @@ Time format: 12-hour
|
||||
If only the timezone is known, we still include the section and instruct the model
|
||||
to assume UTC for unknown time references.
|
||||
|
||||
## System event lines (local by default)
|
||||
## System event lines (UTC)
|
||||
|
||||
Queued system events inserted into agent context are prefixed with a timestamp using the
|
||||
same timezone selection as message envelopes (default: host-local).
|
||||
Queued system events inserted into agent context are prefixed with a UTC timestamp:
|
||||
|
||||
```
|
||||
System: [2026-01-12 12:19:17 PST] Model switched.
|
||||
System: [2026-01-12T20:19:17Z] Model switched.
|
||||
```
|
||||
|
||||
### Configure user timezone + format
|
||||
|
||||
@@ -88,7 +88,6 @@ If a prompt is required but no UI is reachable, fallback decides:
|
||||
Allowlists are **per agent**. If multiple agents exist, switch which agent you’re
|
||||
editing in the macOS app. Patterns are **case-insensitive glob matches**.
|
||||
Patterns should resolve to **binary paths** (basename-only entries are ignored).
|
||||
Legacy `agents.default` entries are migrated to `agents.main` on load.
|
||||
|
||||
Examples:
|
||||
- `~/Projects/**/bin/bird`
|
||||
|
||||
@@ -7,32 +7,19 @@ import { describe, expect, it } from "vitest";
|
||||
import type { ClawdbotPluginApi, ClawdbotPluginToolContext } from "../../../src/plugins/types.js";
|
||||
import { createLobsterTool } from "./lobster-tool.js";
|
||||
|
||||
async function writeFakeLobsterScript(scriptBody: string, prefix = "clawdbot-lobster-plugin-") {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), prefix));
|
||||
const isWindows = process.platform === "win32";
|
||||
|
||||
if (isWindows) {
|
||||
const scriptPath = path.join(dir, "lobster.js");
|
||||
const cmdPath = path.join(dir, "lobster.cmd");
|
||||
await fs.writeFile(scriptPath, scriptBody, { encoding: "utf8" });
|
||||
const cmd = `@echo off\r\n"${process.execPath}" "${scriptPath}" %*\r\n`;
|
||||
await fs.writeFile(cmdPath, cmd, { encoding: "utf8" });
|
||||
return { dir, binPath: cmdPath };
|
||||
}
|
||||
|
||||
async function writeFakeLobster(params: {
|
||||
payload: unknown;
|
||||
}) {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-lobster-plugin-"));
|
||||
const binPath = path.join(dir, "lobster");
|
||||
const file = `#!/usr/bin/env node\n${scriptBody}\n`;
|
||||
|
||||
const file = `#!/usr/bin/env node\n` +
|
||||
`process.stdout.write(JSON.stringify(${JSON.stringify(params.payload)}));\n`;
|
||||
|
||||
await fs.writeFile(binPath, file, { encoding: "utf8", mode: 0o755 });
|
||||
return { dir, binPath };
|
||||
}
|
||||
|
||||
async function writeFakeLobster(params: { payload: unknown }) {
|
||||
const scriptBody =
|
||||
`const payload = ${JSON.stringify(params.payload)};\n` +
|
||||
`process.stdout.write(JSON.stringify(payload));\n`;
|
||||
return await writeFakeLobsterScript(scriptBody);
|
||||
}
|
||||
|
||||
function fakeApi(): ClawdbotPluginApi {
|
||||
return {
|
||||
id: "lobster",
|
||||
@@ -95,10 +82,12 @@ describe("lobster plugin tool", () => {
|
||||
});
|
||||
|
||||
it("rejects invalid JSON from lobster", async () => {
|
||||
const { binPath } = await writeFakeLobsterScript(
|
||||
`process.stdout.write("nope");\n`,
|
||||
"clawdbot-lobster-plugin-bad-",
|
||||
);
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-lobster-plugin-bad-"));
|
||||
const binPath = path.join(dir, "lobster");
|
||||
await fs.writeFile(binPath, `#!/usr/bin/env node\nprocess.stdout.write('nope');\n`, {
|
||||
encoding: "utf8",
|
||||
mode: 0o755,
|
||||
});
|
||||
|
||||
const tool = createLobsterTool(fakeApi());
|
||||
await expect(
|
||||
|
||||
@@ -54,7 +54,6 @@ import { callGatewayTool } from "./tools/gateway.js";
|
||||
import { listNodes, resolveNodeIdFromList } from "./tools/nodes-utils.js";
|
||||
import { getShellConfig, sanitizeBinaryOutput } from "./shell-utils.js";
|
||||
import { buildCursorPositionResponse, stripDsrRequests } from "./pty-dsr.js";
|
||||
import { parseAgentSessionKey, resolveAgentIdFromSessionKey } from "../routing/session-key.js";
|
||||
|
||||
const DEFAULT_MAX_OUTPUT = clampNumber(
|
||||
readEnvInt("PI_BASH_MAX_OUTPUT_CHARS"),
|
||||
@@ -660,11 +659,6 @@ export function createExecTool(
|
||||
const notifyOnExit = defaults?.notifyOnExit !== false;
|
||||
const notifySessionKey = defaults?.sessionKey?.trim() || undefined;
|
||||
const approvalRunningNoticeMs = resolveApprovalRunningNoticeMs(defaults?.approvalRunningNoticeMs);
|
||||
// Derive agentId only when sessionKey is an agent session key.
|
||||
const parsedAgentSession = parseAgentSessionKey(defaults?.sessionKey);
|
||||
const agentId =
|
||||
defaults?.agentId ??
|
||||
(parsedAgentSession ? resolveAgentIdFromSessionKey(defaults?.sessionKey) : undefined);
|
||||
|
||||
return {
|
||||
name: "exec",
|
||||
@@ -805,7 +799,7 @@ export function createExecTool(
|
||||
|
||||
if (host === "node") {
|
||||
const approvals = resolveExecApprovals(
|
||||
agentId,
|
||||
defaults?.agentId,
|
||||
host === "node" ? { security: "allowlist" } : undefined,
|
||||
);
|
||||
const hostSecurity = minSecurity(security, approvals.agent.security);
|
||||
@@ -871,7 +865,7 @@ export function createExecTool(
|
||||
cwd: workdir,
|
||||
env: nodeEnv,
|
||||
timeoutMs: typeof params.timeout === "number" ? params.timeout * 1000 : undefined,
|
||||
agentId,
|
||||
agentId: defaults?.agentId,
|
||||
sessionKey: defaults?.sessionKey,
|
||||
approved: approvedByAsk,
|
||||
approvalDecision: approvalDecision ?? undefined,
|
||||
@@ -901,9 +895,9 @@ export function createExecTool(
|
||||
host: "node",
|
||||
security: hostSecurity,
|
||||
ask: hostAsk,
|
||||
agentId,
|
||||
resolvedPath: null,
|
||||
sessionKey: defaults?.sessionKey ?? null,
|
||||
agentId: defaults?.agentId,
|
||||
resolvedPath: undefined,
|
||||
sessionKey: defaults?.sessionKey,
|
||||
timeoutMs: DEFAULT_APPROVAL_TIMEOUT_MS,
|
||||
},
|
||||
)) as { decision?: string } | null;
|
||||
@@ -1032,7 +1026,7 @@ export function createExecTool(
|
||||
}
|
||||
|
||||
if (host === "gateway") {
|
||||
const approvals = resolveExecApprovals(agentId, { security: "allowlist" });
|
||||
const approvals = resolveExecApprovals(defaults?.agentId, { security: "allowlist" });
|
||||
const hostSecurity = minSecurity(security, approvals.agent.security);
|
||||
const hostAsk = maxAsk(ask, approvals.agent.ask);
|
||||
const askFallback = approvals.agent.askFallback;
|
||||
@@ -1066,7 +1060,7 @@ export function createExecTool(
|
||||
const approvalSlug = createApprovalSlug(approvalId);
|
||||
const expiresAtMs = Date.now() + DEFAULT_APPROVAL_TIMEOUT_MS;
|
||||
const contextKey = `exec:${approvalId}`;
|
||||
const resolvedPath = analysis.segments[0]?.resolution?.resolvedPath ?? null;
|
||||
const resolvedPath = analysis.segments[0]?.resolution?.resolvedPath;
|
||||
const noticeSeconds = Math.max(1, Math.round(approvalRunningNoticeMs / 1000));
|
||||
const commandText = params.command;
|
||||
const effectiveTimeout =
|
||||
@@ -1086,9 +1080,9 @@ export function createExecTool(
|
||||
host: "gateway",
|
||||
security: hostSecurity,
|
||||
ask: hostAsk,
|
||||
agentId,
|
||||
agentId: defaults?.agentId,
|
||||
resolvedPath,
|
||||
sessionKey: defaults?.sessionKey ?? null,
|
||||
sessionKey: defaults?.sessionKey,
|
||||
timeoutMs: DEFAULT_APPROVAL_TIMEOUT_MS,
|
||||
},
|
||||
)) as { decision?: string } | null;
|
||||
@@ -1129,7 +1123,7 @@ export function createExecTool(
|
||||
for (const segment of analysis.segments) {
|
||||
const pattern = segment.resolution?.resolvedPath ?? "";
|
||||
if (pattern) {
|
||||
addAllowlistEntry(approvals.file, agentId, pattern);
|
||||
addAllowlistEntry(approvals.file, defaults?.agentId, pattern);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1158,7 +1152,7 @@ export function createExecTool(
|
||||
seen.add(match.pattern);
|
||||
recordAllowlistUse(
|
||||
approvals.file,
|
||||
agentId,
|
||||
defaults?.agentId,
|
||||
match,
|
||||
commandText,
|
||||
resolvedPath ?? undefined,
|
||||
@@ -1248,7 +1242,7 @@ export function createExecTool(
|
||||
seen.add(match.pattern);
|
||||
recordAllowlistUse(
|
||||
approvals.file,
|
||||
agentId,
|
||||
defaults?.agentId,
|
||||
match,
|
||||
params.command,
|
||||
analysis.segments[0]?.resolution?.resolvedPath,
|
||||
|
||||
@@ -44,8 +44,6 @@ describe("resolveOpencodeZenModelApi", () => {
|
||||
expect(resolveOpencodeZenModelApi("minimax-m2.1-free")).toBe("anthropic-messages");
|
||||
expect(resolveOpencodeZenModelApi("gemini-3-pro")).toBe("google-generative-ai");
|
||||
expect(resolveOpencodeZenModelApi("gpt-5.2")).toBe("openai-responses");
|
||||
expect(resolveOpencodeZenModelApi("alpha-gd4")).toBe("openai-completions");
|
||||
expect(resolveOpencodeZenModelApi("big-pickle")).toBe("openai-completions");
|
||||
expect(resolveOpencodeZenModelApi("glm-4.7-free")).toBe("openai-completions");
|
||||
expect(resolveOpencodeZenModelApi("some-unknown-model")).toBe("openai-completions");
|
||||
});
|
||||
|
||||
@@ -87,19 +87,19 @@ export function resolveOpencodeZenAlias(modelIdOrAlias: string): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* OpenCode Zen routes models to specific API shapes by family.
|
||||
* OpenCode Zen routes models to different APIs based on model family.
|
||||
*/
|
||||
export function resolveOpencodeZenModelApi(modelId: string): ModelApi {
|
||||
const lower = modelId.toLowerCase();
|
||||
if (lower.startsWith("gpt-")) {
|
||||
return "openai-responses";
|
||||
}
|
||||
if (lower.startsWith("claude-") || lower.startsWith("minimax-")) {
|
||||
if (lower.startsWith("claude-") || lower.startsWith("minimax") || lower.startsWith("alpha-gd4")) {
|
||||
return "anthropic-messages";
|
||||
}
|
||||
if (lower.startsWith("gemini-")) {
|
||||
return "google-generative-ai";
|
||||
}
|
||||
if (lower.startsWith("gpt-")) {
|
||||
return "openai-responses";
|
||||
}
|
||||
return "openai-completions";
|
||||
}
|
||||
|
||||
|
||||
@@ -200,7 +200,7 @@ export function buildAgentSystemPrompt(params: {
|
||||
browser: "Control web browser",
|
||||
canvas: "Present/eval/snapshot the Canvas",
|
||||
nodes: "List/describe/notify/camera/screen on paired nodes",
|
||||
cron: "Manage cron jobs and wake events (use for reminders; when scheduling a reminder, write the systemEvent text as something that will read like a reminder when it fires, and mention that it is a reminder depending on the time gap between setting and firing; include recent context in reminder text if appropriate)",
|
||||
cron: "Manage cron jobs and wake events (use for reminders; set systemEvent to a reminder-style message, especially after long delays; include recent context in reminder text if appropriate)",
|
||||
message: "Send messages and channel actions",
|
||||
gateway: "Restart, apply config, or run updates on the running Clawdbot process",
|
||||
agents_list: "List agent ids allowed for sessions_spawn",
|
||||
@@ -351,7 +351,7 @@ export function buildAgentSystemPrompt(params: {
|
||||
"- browser: control clawd's dedicated browser",
|
||||
"- canvas: present/eval/snapshot the Canvas",
|
||||
"- nodes: list/describe/notify/camera/screen on paired nodes",
|
||||
"- cron: manage cron jobs and wake events (use for reminders; when scheduling a reminder, write the systemEvent text as something that will read like a reminder when it fires, and mention that it is a reminder depending on the time gap between setting and firing; include recent context in reminder text if appropriate)",
|
||||
"- cron: manage cron jobs and wake events (use for reminders; set systemEvent to a reminder-style message, especially after long delays; include recent context in reminder text if appropriate)",
|
||||
"- sessions_list: list sessions",
|
||||
"- sessions_history: fetch session history",
|
||||
"- sessions_send: send to another session",
|
||||
|
||||
@@ -18,7 +18,6 @@ describe("formatAgentEnvelope", () => {
|
||||
host: "mac-mini",
|
||||
ip: "10.0.0.5",
|
||||
timestamp: ts,
|
||||
envelope: { timezone: "utc" },
|
||||
body: "hello",
|
||||
});
|
||||
|
||||
@@ -27,7 +26,7 @@ describe("formatAgentEnvelope", () => {
|
||||
expect(body).toBe("[WebChat user1 mac-mini 10.0.0.5 2025-01-02T03:04Z] hello");
|
||||
});
|
||||
|
||||
it("formats timestamps in local timezone by default", () => {
|
||||
it("formats timestamps in UTC regardless of local timezone", () => {
|
||||
const originalTz = process.env.TZ;
|
||||
process.env.TZ = "America/Los_Angeles";
|
||||
|
||||
@@ -40,10 +39,10 @@ describe("formatAgentEnvelope", () => {
|
||||
|
||||
process.env.TZ = originalTz;
|
||||
|
||||
expect(body).toMatch(/\[WebChat 2025-01-01 19:04 [^\]]+\] hello/);
|
||||
expect(body).toBe("[WebChat 2025-01-02T03:04Z] hello");
|
||||
});
|
||||
|
||||
it("formats timestamps in UTC when configured", () => {
|
||||
it("formats timestamps in local timezone when configured", () => {
|
||||
const originalTz = process.env.TZ;
|
||||
process.env.TZ = "America/Los_Angeles";
|
||||
|
||||
@@ -51,13 +50,13 @@ describe("formatAgentEnvelope", () => {
|
||||
const body = formatAgentEnvelope({
|
||||
channel: "WebChat",
|
||||
timestamp: ts,
|
||||
envelope: { timezone: "utc" },
|
||||
envelope: { timezone: "local" },
|
||||
body: "hello",
|
||||
});
|
||||
|
||||
process.env.TZ = originalTz;
|
||||
|
||||
expect(body).toBe("[WebChat 2025-01-02T03:04Z] hello");
|
||||
expect(body).toMatch(/\[WebChat 2025-01-01 19:04 [^\]]+\] hello/);
|
||||
});
|
||||
|
||||
it("formats timestamps in user timezone when configured", () => {
|
||||
|
||||
@@ -16,7 +16,7 @@ export type AgentEnvelopeParams = {
|
||||
|
||||
export type EnvelopeFormatOptions = {
|
||||
/**
|
||||
* "local" (default), "utc", "user", or an explicit IANA timezone string.
|
||||
* "utc" (default), "local", "user", or an explicit IANA timezone string.
|
||||
*/
|
||||
timezone?: string;
|
||||
/**
|
||||
@@ -59,7 +59,7 @@ function normalizeEnvelopeOptions(options?: EnvelopeFormatOptions): NormalizedEn
|
||||
const includeTimestamp = options?.includeTimestamp !== false;
|
||||
const includeElapsed = options?.includeElapsed !== false;
|
||||
return {
|
||||
timezone: options?.timezone?.trim() || "local",
|
||||
timezone: options?.timezone?.trim() || "utc",
|
||||
includeTimestamp,
|
||||
includeElapsed,
|
||||
userTimezone: options?.userTimezone,
|
||||
@@ -77,7 +77,7 @@ function resolveExplicitTimezone(value: string): string | undefined {
|
||||
|
||||
function resolveEnvelopeTimezone(options: NormalizedEnvelopeOptions): ResolvedEnvelopeTimezone {
|
||||
const trimmed = options.timezone?.trim();
|
||||
if (!trimmed) return { mode: "local" };
|
||||
if (!trimmed) return { mode: "utc" };
|
||||
const lowered = trimmed.toLowerCase();
|
||||
if (lowered === "utc" || lowered === "gmt") return { mode: "utc" };
|
||||
if (lowered === "local" || lowered === "host") return { mode: "local" };
|
||||
|
||||
@@ -5,10 +5,8 @@ import { enqueueSystemEvent, resetSystemEventsForTest } from "../../infra/system
|
||||
import { prependSystemEvents } from "./session-updates.js";
|
||||
|
||||
describe("prependSystemEvents", () => {
|
||||
it("adds a local timestamp to queued system events by default", async () => {
|
||||
it("adds a UTC timestamp to queued system events", async () => {
|
||||
vi.useFakeTimers();
|
||||
const originalTz = process.env.TZ;
|
||||
process.env.TZ = "America/Los_Angeles";
|
||||
const timestamp = new Date("2026-01-12T20:19:17Z");
|
||||
vi.setSystemTime(timestamp);
|
||||
|
||||
@@ -22,10 +20,11 @@ describe("prependSystemEvents", () => {
|
||||
prefixedBodyBase: "User: hi",
|
||||
});
|
||||
|
||||
expect(result).toMatch(/System: \[2026-01-12 12:19:17 [^\]]+\] Model switched\./);
|
||||
const expectedTimestamp = "2026-01-12T20:19:17Z";
|
||||
|
||||
expect(result).toContain(`System: [${expectedTimestamp}] Model switched.`);
|
||||
|
||||
resetSystemEventsForTest();
|
||||
process.env.TZ = originalTz;
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import crypto from "node:crypto";
|
||||
|
||||
import { resolveUserTimezone } from "../../agents/date-time.js";
|
||||
import { buildWorkspaceSkillSnapshot } from "../../agents/skills.js";
|
||||
import { ensureSkillsWatcher, getSkillsSnapshotVersion } from "../../agents/skills/refresh.js";
|
||||
import type { ClawdbotConfig } from "../../config/config.js";
|
||||
@@ -28,32 +27,9 @@ export async function prependSystemEvents(params: {
|
||||
return trimmed;
|
||||
};
|
||||
|
||||
const resolveExplicitTimezone = (value: string): string | undefined => {
|
||||
try {
|
||||
new Intl.DateTimeFormat("en-US", { timeZone: value }).format(new Date());
|
||||
return value;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const resolveSystemEventTimezone = (cfg: ClawdbotConfig) => {
|
||||
const raw = cfg.agents?.defaults?.envelopeTimezone?.trim();
|
||||
if (!raw) return { mode: "local" as const };
|
||||
const lowered = raw.toLowerCase();
|
||||
if (lowered === "utc" || lowered === "gmt") return { mode: "utc" as const };
|
||||
if (lowered === "local" || lowered === "host") return { mode: "local" as const };
|
||||
if (lowered === "user") {
|
||||
return {
|
||||
mode: "iana" as const,
|
||||
timeZone: resolveUserTimezone(cfg.agents?.defaults?.userTimezone),
|
||||
};
|
||||
}
|
||||
const explicit = resolveExplicitTimezone(raw);
|
||||
return explicit ? { mode: "iana" as const, timeZone: explicit } : { mode: "local" as const };
|
||||
};
|
||||
|
||||
const formatUtcTimestamp = (date: Date): string => {
|
||||
const formatSystemEventTimestamp = (ts: number) => {
|
||||
const date = new Date(ts);
|
||||
if (Number.isNaN(date.getTime())) return "unknown-time";
|
||||
const yyyy = String(date.getUTCFullYear()).padStart(4, "0");
|
||||
const mm = String(date.getUTCMonth() + 1).padStart(2, "0");
|
||||
const dd = String(date.getUTCDate()).padStart(2, "0");
|
||||
@@ -63,42 +39,6 @@ export async function prependSystemEvents(params: {
|
||||
return `${yyyy}-${mm}-${dd}T${hh}:${min}:${sec}Z`;
|
||||
};
|
||||
|
||||
const formatZonedTimestamp = (date: Date, timeZone?: string): string | undefined => {
|
||||
const parts = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone,
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
hourCycle: "h23",
|
||||
timeZoneName: "short",
|
||||
}).formatToParts(date);
|
||||
const pick = (type: string) => parts.find((part) => part.type === type)?.value;
|
||||
const yyyy = pick("year");
|
||||
const mm = pick("month");
|
||||
const dd = pick("day");
|
||||
const hh = pick("hour");
|
||||
const min = pick("minute");
|
||||
const sec = pick("second");
|
||||
const tz = [...parts]
|
||||
.reverse()
|
||||
.find((part) => part.type === "timeZoneName")
|
||||
?.value?.trim();
|
||||
if (!yyyy || !mm || !dd || !hh || !min || !sec) return undefined;
|
||||
return `${yyyy}-${mm}-${dd} ${hh}:${min}:${sec}${tz ? ` ${tz}` : ""}`;
|
||||
};
|
||||
|
||||
const formatSystemEventTimestamp = (ts: number, cfg: ClawdbotConfig) => {
|
||||
const date = new Date(ts);
|
||||
if (Number.isNaN(date.getTime())) return "unknown-time";
|
||||
const zone = resolveSystemEventTimezone(cfg);
|
||||
if (zone.mode === "utc") return formatUtcTimestamp(date);
|
||||
if (zone.mode === "local") return formatZonedTimestamp(date) ?? "unknown-time";
|
||||
return formatZonedTimestamp(date, zone.timeZone) ?? "unknown-time";
|
||||
};
|
||||
|
||||
const systemLines: string[] = [];
|
||||
const queued = drainSystemEventEntries(params.sessionKey);
|
||||
systemLines.push(
|
||||
@@ -106,7 +46,7 @@ export async function prependSystemEvents(params: {
|
||||
.map((event) => {
|
||||
const compacted = compactSystemEvent(event.text);
|
||||
if (!compacted) return null;
|
||||
return `[${formatSystemEventTimestamp(event.ts, params.cfg)}] ${compacted}`;
|
||||
return `[${formatSystemEventTimestamp(event.ts)}] ${compacted}`;
|
||||
})
|
||||
.filter((v): v is string => Boolean(v)),
|
||||
);
|
||||
|
||||
@@ -34,7 +34,6 @@ export type AgentIdentity = {
|
||||
creature?: string;
|
||||
vibe?: string;
|
||||
theme?: string;
|
||||
avatar?: string;
|
||||
};
|
||||
|
||||
export function listAgentEntries(cfg: ClawdbotConfig): AgentEntry[] {
|
||||
@@ -91,7 +90,6 @@ export function parseIdentityMarkdown(content: string): AgentIdentity {
|
||||
if (label === "creature") identity.creature = value;
|
||||
if (label === "vibe") identity.vibe = value;
|
||||
if (label === "theme") identity.theme = value;
|
||||
if (label === "avatar") identity.avatar = value;
|
||||
}
|
||||
return identity;
|
||||
}
|
||||
@@ -101,14 +99,7 @@ export function loadAgentIdentity(workspace: string): AgentIdentity | null {
|
||||
try {
|
||||
const content = fs.readFileSync(identityPath, "utf-8");
|
||||
const parsed = parseIdentityMarkdown(content);
|
||||
if (
|
||||
!parsed.name &&
|
||||
!parsed.emoji &&
|
||||
!parsed.theme &&
|
||||
!parsed.creature &&
|
||||
!parsed.vibe &&
|
||||
!parsed.avatar
|
||||
) {
|
||||
if (!parsed.name && !parsed.emoji && !parsed.theme && !parsed.creature && !parsed.vibe) {
|
||||
return null;
|
||||
}
|
||||
return parsed;
|
||||
|
||||
@@ -154,6 +154,4 @@ export type IdentityConfig = {
|
||||
name?: string;
|
||||
theme?: string;
|
||||
emoji?: string;
|
||||
/** Path to a custom avatar image (relative to workspace or absolute). */
|
||||
avatar?: string;
|
||||
};
|
||||
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
normalizeSafeBins,
|
||||
resolveCommandResolution,
|
||||
resolveExecApprovals,
|
||||
resolveExecApprovalsFromFile,
|
||||
type ExecAllowlistEntry,
|
||||
} from "./exec-approvals.js";
|
||||
|
||||
@@ -228,35 +227,3 @@ describe("exec approvals wildcard agent", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("exec approvals default agent migration", () => {
|
||||
it("migrates legacy default agent entries to main", () => {
|
||||
const file = {
|
||||
version: 1,
|
||||
agents: {
|
||||
default: { allowlist: [{ pattern: "/bin/legacy" }] },
|
||||
},
|
||||
};
|
||||
const resolved = resolveExecApprovalsFromFile({ file });
|
||||
expect(resolved.allowlist.map((entry) => entry.pattern)).toEqual(["/bin/legacy"]);
|
||||
expect(resolved.file.agents?.default).toBeUndefined();
|
||||
expect(resolved.file.agents?.main?.allowlist?.[0]?.pattern).toBe("/bin/legacy");
|
||||
});
|
||||
|
||||
it("prefers main agent settings when both main and default exist", () => {
|
||||
const file = {
|
||||
version: 1,
|
||||
agents: {
|
||||
main: { ask: "always", allowlist: [{ pattern: "/bin/main" }] },
|
||||
default: { ask: "off", allowlist: [{ pattern: "/bin/legacy" }] },
|
||||
},
|
||||
};
|
||||
const resolved = resolveExecApprovalsFromFile({ file });
|
||||
expect(resolved.agent.ask).toBe("always");
|
||||
expect(resolved.allowlist.map((entry) => entry.pattern)).toEqual([
|
||||
"/bin/main",
|
||||
"/bin/legacy",
|
||||
]);
|
||||
expect(resolved.file.agents?.default).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,8 +4,6 @@ import net from "node:net";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { DEFAULT_AGENT_ID } from "../routing/session-key.js";
|
||||
|
||||
export type ExecHost = "sandbox" | "gateway" | "node";
|
||||
export type ExecSecurity = "deny" | "allowlist" | "full";
|
||||
export type ExecAsk = "off" | "on-miss" | "always";
|
||||
@@ -86,35 +84,6 @@ export function resolveExecApprovalsSocketPath(): string {
|
||||
return expandHome(DEFAULT_SOCKET);
|
||||
}
|
||||
|
||||
function normalizeAllowlistPattern(value: string | undefined): string | null {
|
||||
const trimmed = value?.trim() ?? "";
|
||||
return trimmed ? trimmed.toLowerCase() : null;
|
||||
}
|
||||
|
||||
function mergeLegacyAgent(
|
||||
current: ExecApprovalsAgent,
|
||||
legacy: ExecApprovalsAgent,
|
||||
): ExecApprovalsAgent {
|
||||
const allowlist: ExecAllowlistEntry[] = [];
|
||||
const seen = new Set<string>();
|
||||
const pushEntry = (entry: ExecAllowlistEntry) => {
|
||||
const key = normalizeAllowlistPattern(entry.pattern);
|
||||
if (!key || seen.has(key)) return;
|
||||
seen.add(key);
|
||||
allowlist.push(entry);
|
||||
};
|
||||
for (const entry of current.allowlist ?? []) pushEntry(entry);
|
||||
for (const entry of legacy.allowlist ?? []) pushEntry(entry);
|
||||
|
||||
return {
|
||||
security: current.security ?? legacy.security,
|
||||
ask: current.ask ?? legacy.ask,
|
||||
askFallback: current.askFallback ?? legacy.askFallback,
|
||||
autoAllowSkills: current.autoAllowSkills ?? legacy.autoAllowSkills,
|
||||
allowlist: allowlist.length > 0 ? allowlist : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function ensureDir(filePath: string) {
|
||||
const dir = path.dirname(filePath);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
@@ -123,13 +92,6 @@ function ensureDir(filePath: string) {
|
||||
export function normalizeExecApprovals(file: ExecApprovalsFile): ExecApprovalsFile {
|
||||
const socketPath = file.socket?.path?.trim();
|
||||
const token = file.socket?.token?.trim();
|
||||
const agents = { ...file.agents };
|
||||
const legacyDefault = agents.default;
|
||||
if (legacyDefault) {
|
||||
const main = agents[DEFAULT_AGENT_ID];
|
||||
agents[DEFAULT_AGENT_ID] = main ? mergeLegacyAgent(main, legacyDefault) : legacyDefault;
|
||||
delete agents.default;
|
||||
}
|
||||
const normalized: ExecApprovalsFile = {
|
||||
version: 1,
|
||||
socket: {
|
||||
@@ -142,7 +104,7 @@ export function normalizeExecApprovals(file: ExecApprovalsFile): ExecApprovalsFi
|
||||
askFallback: file.defaults?.askFallback,
|
||||
autoAllowSkills: file.defaults?.autoAllowSkills,
|
||||
},
|
||||
agents,
|
||||
agents: file.agents ?? {},
|
||||
};
|
||||
return normalized;
|
||||
}
|
||||
@@ -269,7 +231,7 @@ export function resolveExecApprovalsFromFile(params: {
|
||||
}): ExecApprovalsResolved {
|
||||
const file = normalizeExecApprovals(params.file);
|
||||
const defaults = file.defaults ?? {};
|
||||
const agentKey = params.agentId ?? DEFAULT_AGENT_ID;
|
||||
const agentKey = params.agentId ?? "default";
|
||||
const agent = file.agents?.[agentKey] ?? {};
|
||||
const wildcard = file.agents?.["*"] ?? {};
|
||||
const fallbackSecurity = params.overrides?.security ?? DEFAULT_SECURITY;
|
||||
@@ -734,7 +696,7 @@ export function recordAllowlistUse(
|
||||
command: string,
|
||||
resolvedPath?: string,
|
||||
) {
|
||||
const target = agentId ?? DEFAULT_AGENT_ID;
|
||||
const target = agentId ?? "default";
|
||||
const agents = approvals.agents ?? {};
|
||||
const existing = agents[target] ?? {};
|
||||
const allowlist = Array.isArray(existing.allowlist) ? existing.allowlist : [];
|
||||
@@ -758,7 +720,7 @@ export function addAllowlistEntry(
|
||||
agentId: string | undefined,
|
||||
pattern: string,
|
||||
) {
|
||||
const target = agentId ?? DEFAULT_AGENT_ID;
|
||||
const target = agentId ?? "default";
|
||||
const agents = approvals.agents ?? {};
|
||||
const existing = agents[target] ?? {};
|
||||
const allowlist = Array.isArray(existing.allowlist) ? existing.allowlist : [];
|
||||
|
||||
@@ -141,101 +141,83 @@ describe("createTelegramBot", () => {
|
||||
// groupPolicy tests
|
||||
|
||||
it("accepts group messages when mentionPatterns match (without @botUsername)", async () => {
|
||||
const originalTz = process.env.TZ;
|
||||
process.env.TZ = "UTC";
|
||||
onSpy.mockReset();
|
||||
const replySpy = replyModule.__replySpy as unknown as ReturnType<typeof vi.fn>;
|
||||
replySpy.mockReset();
|
||||
|
||||
try {
|
||||
onSpy.mockReset();
|
||||
const replySpy = replyModule.__replySpy as unknown as ReturnType<typeof vi.fn>;
|
||||
replySpy.mockReset();
|
||||
|
||||
loadConfig.mockReturnValue({
|
||||
identity: { name: "Bert" },
|
||||
messages: { groupChat: { mentionPatterns: ["\\bbert\\b"] } },
|
||||
channels: {
|
||||
telegram: {
|
||||
groupPolicy: "open",
|
||||
groups: { "*": { requireMention: true } },
|
||||
},
|
||||
loadConfig.mockReturnValue({
|
||||
identity: { name: "Bert" },
|
||||
messages: { groupChat: { mentionPatterns: ["\\bbert\\b"] } },
|
||||
channels: {
|
||||
telegram: {
|
||||
groupPolicy: "open",
|
||||
groups: { "*": { requireMention: true } },
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
createTelegramBot({ token: "tok" });
|
||||
const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>;
|
||||
createTelegramBot({ token: "tok" });
|
||||
const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>;
|
||||
|
||||
await handler({
|
||||
message: {
|
||||
chat: { id: 7, type: "group", title: "Test Group" },
|
||||
text: "bert: introduce yourself",
|
||||
date: 1736380800,
|
||||
message_id: 1,
|
||||
from: { id: 9, first_name: "Ada" },
|
||||
},
|
||||
me: { username: "clawdbot_bot" },
|
||||
getFile: async () => ({ download: async () => new Uint8Array() }),
|
||||
});
|
||||
await handler({
|
||||
message: {
|
||||
chat: { id: 7, type: "group", title: "Test Group" },
|
||||
text: "bert: introduce yourself",
|
||||
date: 1736380800,
|
||||
message_id: 1,
|
||||
from: { id: 9, first_name: "Ada" },
|
||||
},
|
||||
me: { username: "clawdbot_bot" },
|
||||
getFile: async () => ({ download: async () => new Uint8Array() }),
|
||||
});
|
||||
|
||||
expect(replySpy).toHaveBeenCalledTimes(1);
|
||||
const payload = replySpy.mock.calls[0][0];
|
||||
expect(payload.WasMentioned).toBe(true);
|
||||
expect(payload.SenderName).toBe("Ada");
|
||||
expect(payload.SenderId).toBe("9");
|
||||
expect(payload.Body).toMatch(
|
||||
/^\[Telegram Test Group id:7 (\+\d+[smhd] )?2025-01-09 00:00 [^\]]+\]/,
|
||||
);
|
||||
} finally {
|
||||
process.env.TZ = originalTz;
|
||||
}
|
||||
expect(replySpy).toHaveBeenCalledTimes(1);
|
||||
const payload = replySpy.mock.calls[0][0];
|
||||
expect(payload.WasMentioned).toBe(true);
|
||||
expect(payload.SenderName).toBe("Ada");
|
||||
expect(payload.SenderId).toBe("9");
|
||||
expect(payload.Body).toMatch(/^\[Telegram Test Group id:7 (\+\d+[smhd] )?2025-01-09T00:00Z\]/);
|
||||
});
|
||||
it("keeps group envelope headers stable (sender identity is separate)", async () => {
|
||||
const originalTz = process.env.TZ;
|
||||
process.env.TZ = "UTC";
|
||||
onSpy.mockReset();
|
||||
const replySpy = replyModule.__replySpy as unknown as ReturnType<typeof vi.fn>;
|
||||
replySpy.mockReset();
|
||||
|
||||
try {
|
||||
onSpy.mockReset();
|
||||
const replySpy = replyModule.__replySpy as unknown as ReturnType<typeof vi.fn>;
|
||||
replySpy.mockReset();
|
||||
|
||||
loadConfig.mockReturnValue({
|
||||
channels: {
|
||||
telegram: {
|
||||
groupPolicy: "open",
|
||||
groups: { "*": { requireMention: false } },
|
||||
},
|
||||
loadConfig.mockReturnValue({
|
||||
channels: {
|
||||
telegram: {
|
||||
groupPolicy: "open",
|
||||
groups: { "*": { requireMention: false } },
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
createTelegramBot({ token: "tok" });
|
||||
const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>;
|
||||
createTelegramBot({ token: "tok" });
|
||||
const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>;
|
||||
|
||||
await handler({
|
||||
message: {
|
||||
chat: { id: 42, type: "group", title: "Ops" },
|
||||
text: "hello",
|
||||
date: 1736380800,
|
||||
message_id: 2,
|
||||
from: {
|
||||
id: 99,
|
||||
first_name: "Ada",
|
||||
last_name: "Lovelace",
|
||||
username: "ada",
|
||||
},
|
||||
await handler({
|
||||
message: {
|
||||
chat: { id: 42, type: "group", title: "Ops" },
|
||||
text: "hello",
|
||||
date: 1736380800,
|
||||
message_id: 2,
|
||||
from: {
|
||||
id: 99,
|
||||
first_name: "Ada",
|
||||
last_name: "Lovelace",
|
||||
username: "ada",
|
||||
},
|
||||
me: { username: "clawdbot_bot" },
|
||||
getFile: async () => ({ download: async () => new Uint8Array() }),
|
||||
});
|
||||
},
|
||||
me: { username: "clawdbot_bot" },
|
||||
getFile: async () => ({ download: async () => new Uint8Array() }),
|
||||
});
|
||||
|
||||
expect(replySpy).toHaveBeenCalledTimes(1);
|
||||
const payload = replySpy.mock.calls[0][0];
|
||||
expect(payload.SenderName).toBe("Ada Lovelace");
|
||||
expect(payload.SenderId).toBe("99");
|
||||
expect(payload.SenderUsername).toBe("ada");
|
||||
expect(payload.Body).toMatch(
|
||||
/^\[Telegram Ops id:42 (\+\d+[smhd] )?2025-01-09 00:00 [^\]]+\]/,
|
||||
);
|
||||
} finally {
|
||||
process.env.TZ = originalTz;
|
||||
}
|
||||
expect(replySpy).toHaveBeenCalledTimes(1);
|
||||
const payload = replySpy.mock.calls[0][0];
|
||||
expect(payload.SenderName).toBe("Ada Lovelace");
|
||||
expect(payload.SenderId).toBe("99");
|
||||
expect(payload.SenderUsername).toBe("ada");
|
||||
expect(payload.Body).toMatch(/^\[Telegram Ops id:42 (\+\d+[smhd] )?2025-01-09T00:00Z\]/);
|
||||
});
|
||||
it("reacts to mention-gated group messages when ackReaction is enabled", async () => {
|
||||
onSpy.mockReset();
|
||||
|
||||
@@ -329,7 +329,7 @@ describe("createTelegramBot", () => {
|
||||
expect(replySpy).toHaveBeenCalledTimes(1);
|
||||
const payload = replySpy.mock.calls[0][0];
|
||||
expect(payload.Body).toMatch(
|
||||
/^\[Telegram Ada Lovelace \(@ada_bot\) id:1234 (\+\d+[smhd] )?2025-01-09 01:00 [^\]]+\]/,
|
||||
/^\[Telegram Ada Lovelace \(@ada_bot\) id:1234 (\+\d+[smhd] )?2025-01-09T00:00Z\]/,
|
||||
);
|
||||
expect(payload.Body).toContain("hello world");
|
||||
} finally {
|
||||
|
||||
@@ -451,7 +451,7 @@ describe("createTelegramBot", () => {
|
||||
expect(replySpy).toHaveBeenCalledTimes(1);
|
||||
const payload = replySpy.mock.calls[0][0];
|
||||
expect(payload.Body).toMatch(
|
||||
/^\[Telegram Ada Lovelace \(@ada_bot\) id:1234 (\+\d+[smhd] )?2025-01-09 01:00 [^\]]+\]/,
|
||||
/^\[Telegram Ada Lovelace \(@ada_bot\) id:1234 (\+\d+[smhd] )?2025-01-09T00:00Z\]/,
|
||||
);
|
||||
expect(payload.Body).toContain("hello world");
|
||||
} finally {
|
||||
@@ -551,104 +551,86 @@ describe("createTelegramBot", () => {
|
||||
});
|
||||
|
||||
it("accepts group messages when mentionPatterns match (without @botUsername)", async () => {
|
||||
const originalTz = process.env.TZ;
|
||||
process.env.TZ = "UTC";
|
||||
onSpy.mockReset();
|
||||
const replySpy = replyModule.__replySpy as unknown as ReturnType<typeof vi.fn>;
|
||||
replySpy.mockReset();
|
||||
|
||||
try {
|
||||
onSpy.mockReset();
|
||||
const replySpy = replyModule.__replySpy as unknown as ReturnType<typeof vi.fn>;
|
||||
replySpy.mockReset();
|
||||
|
||||
loadConfig.mockReturnValue({
|
||||
identity: { name: "Bert" },
|
||||
messages: { groupChat: { mentionPatterns: ["\\bbert\\b"] } },
|
||||
channels: {
|
||||
telegram: {
|
||||
groupPolicy: "open",
|
||||
groups: { "*": { requireMention: true } },
|
||||
},
|
||||
loadConfig.mockReturnValue({
|
||||
identity: { name: "Bert" },
|
||||
messages: { groupChat: { mentionPatterns: ["\\bbert\\b"] } },
|
||||
channels: {
|
||||
telegram: {
|
||||
groupPolicy: "open",
|
||||
groups: { "*": { requireMention: true } },
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
createTelegramBot({ token: "tok" });
|
||||
const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>;
|
||||
createTelegramBot({ token: "tok" });
|
||||
const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>;
|
||||
|
||||
await handler({
|
||||
message: {
|
||||
chat: { id: 7, type: "group", title: "Test Group" },
|
||||
text: "bert: introduce yourself",
|
||||
date: 1736380800,
|
||||
message_id: 1,
|
||||
from: { id: 9, first_name: "Ada" },
|
||||
},
|
||||
me: { username: "clawdbot_bot" },
|
||||
getFile: async () => ({ download: async () => new Uint8Array() }),
|
||||
});
|
||||
await handler({
|
||||
message: {
|
||||
chat: { id: 7, type: "group", title: "Test Group" },
|
||||
text: "bert: introduce yourself",
|
||||
date: 1736380800,
|
||||
message_id: 1,
|
||||
from: { id: 9, first_name: "Ada" },
|
||||
},
|
||||
me: { username: "clawdbot_bot" },
|
||||
getFile: async () => ({ download: async () => new Uint8Array() }),
|
||||
});
|
||||
|
||||
expect(replySpy).toHaveBeenCalledTimes(1);
|
||||
const payload = replySpy.mock.calls[0][0];
|
||||
expectInboundContextContract(payload);
|
||||
expect(payload.WasMentioned).toBe(true);
|
||||
expect(payload.Body).toMatch(
|
||||
/^\[Telegram Test Group id:7 (\+\d+[smhd] )?2025-01-09 00:00 [^\]]+\]/,
|
||||
);
|
||||
expect(payload.SenderName).toBe("Ada");
|
||||
expect(payload.SenderId).toBe("9");
|
||||
} finally {
|
||||
process.env.TZ = originalTz;
|
||||
}
|
||||
expect(replySpy).toHaveBeenCalledTimes(1);
|
||||
const payload = replySpy.mock.calls[0][0];
|
||||
expectInboundContextContract(payload);
|
||||
expect(payload.WasMentioned).toBe(true);
|
||||
expect(payload.Body).toMatch(/^\[Telegram Test Group id:7 (\+\d+[smhd] )?2025-01-09T00:00Z\]/);
|
||||
expect(payload.SenderName).toBe("Ada");
|
||||
expect(payload.SenderId).toBe("9");
|
||||
});
|
||||
|
||||
it("includes sender identity in group envelope headers", async () => {
|
||||
const originalTz = process.env.TZ;
|
||||
process.env.TZ = "UTC";
|
||||
onSpy.mockReset();
|
||||
const replySpy = replyModule.__replySpy as unknown as ReturnType<typeof vi.fn>;
|
||||
replySpy.mockReset();
|
||||
|
||||
try {
|
||||
onSpy.mockReset();
|
||||
const replySpy = replyModule.__replySpy as unknown as ReturnType<typeof vi.fn>;
|
||||
replySpy.mockReset();
|
||||
|
||||
loadConfig.mockReturnValue({
|
||||
channels: {
|
||||
telegram: {
|
||||
groupPolicy: "open",
|
||||
groups: { "*": { requireMention: false } },
|
||||
},
|
||||
loadConfig.mockReturnValue({
|
||||
channels: {
|
||||
telegram: {
|
||||
groupPolicy: "open",
|
||||
groups: { "*": { requireMention: false } },
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
createTelegramBot({ token: "tok" });
|
||||
const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>;
|
||||
createTelegramBot({ token: "tok" });
|
||||
const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>;
|
||||
|
||||
await handler({
|
||||
message: {
|
||||
chat: { id: 42, type: "group", title: "Ops" },
|
||||
text: "hello",
|
||||
date: 1736380800,
|
||||
message_id: 2,
|
||||
from: {
|
||||
id: 99,
|
||||
first_name: "Ada",
|
||||
last_name: "Lovelace",
|
||||
username: "ada",
|
||||
},
|
||||
await handler({
|
||||
message: {
|
||||
chat: { id: 42, type: "group", title: "Ops" },
|
||||
text: "hello",
|
||||
date: 1736380800,
|
||||
message_id: 2,
|
||||
from: {
|
||||
id: 99,
|
||||
first_name: "Ada",
|
||||
last_name: "Lovelace",
|
||||
username: "ada",
|
||||
},
|
||||
me: { username: "clawdbot_bot" },
|
||||
getFile: async () => ({ download: async () => new Uint8Array() }),
|
||||
});
|
||||
},
|
||||
me: { username: "clawdbot_bot" },
|
||||
getFile: async () => ({ download: async () => new Uint8Array() }),
|
||||
});
|
||||
|
||||
expect(replySpy).toHaveBeenCalledTimes(1);
|
||||
const payload = replySpy.mock.calls[0][0];
|
||||
expectInboundContextContract(payload);
|
||||
expect(payload.Body).toMatch(
|
||||
/^\[Telegram Ops id:42 (\+\d+[smhd] )?2025-01-09 00:00 [^\]]+\]/,
|
||||
);
|
||||
expect(payload.SenderName).toBe("Ada Lovelace");
|
||||
expect(payload.SenderId).toBe("99");
|
||||
expect(payload.SenderUsername).toBe("ada");
|
||||
} finally {
|
||||
process.env.TZ = originalTz;
|
||||
}
|
||||
expect(replySpy).toHaveBeenCalledTimes(1);
|
||||
const payload = replySpy.mock.calls[0][0];
|
||||
expectInboundContextContract(payload);
|
||||
expect(payload.Body).toMatch(/^\[Telegram Ops id:42 (\+\d+[smhd] )?2025-01-09T00:00Z\]/);
|
||||
expect(payload.SenderName).toBe("Ada Lovelace");
|
||||
expect(payload.SenderId).toBe("99");
|
||||
expect(payload.SenderUsername).toBe("ada");
|
||||
});
|
||||
|
||||
it("reacts to mention-gated group messages when ackReaction is enabled", async () => {
|
||||
|
||||
@@ -329,11 +329,11 @@ describe("web auto-reply", () => {
|
||||
const firstArgs = resolver.mock.calls[0][0];
|
||||
const secondArgs = resolver.mock.calls[1][0];
|
||||
expect(firstArgs.Body).toMatch(
|
||||
/\[WhatsApp \+1 (\+\d+[smhd] )?2025-01-01 01:00 [^\]]+\] \[clawdbot\] first/,
|
||||
/\[WhatsApp \+1 (\+\d+[smhd] )?2025-01-01T00:00Z\] \[clawdbot\] first/,
|
||||
);
|
||||
expect(firstArgs.Body).not.toContain("second");
|
||||
expect(secondArgs.Body).toMatch(
|
||||
/\[WhatsApp \+1 (\+\d+[smhd] )?2025-01-01 02:00 [^\]]+\] \[clawdbot\] second/,
|
||||
/\[WhatsApp \+1 (\+\d+[smhd] )?2025-01-01T01:00Z\] \[clawdbot\] second/,
|
||||
);
|
||||
expect(secondArgs.Body).not.toContain("first");
|
||||
|
||||
|
||||
@@ -89,12 +89,6 @@
|
||||
color: rgba(134, 142, 150, 1);
|
||||
}
|
||||
|
||||
/* Image avatar support */
|
||||
img.chat-avatar {
|
||||
object-fit: cover;
|
||||
object-position: center;
|
||||
}
|
||||
|
||||
/* Minimal Bubble Design - dynamic width based on content */
|
||||
.chat-bubble {
|
||||
position: relative;
|
||||
|
||||
@@ -105,7 +105,7 @@ export function renderMessageGroup(
|
||||
`;
|
||||
}
|
||||
|
||||
function renderAvatar(role: string, avatarUrl?: string) {
|
||||
function renderAvatar(role: string) {
|
||||
const normalized = normalizeRoleForGrouping(role);
|
||||
const initial =
|
||||
normalized === "user"
|
||||
@@ -123,12 +123,6 @@ function renderAvatar(role: string, avatarUrl?: string) {
|
||||
: normalized === "tool"
|
||||
? "tool"
|
||||
: "other";
|
||||
|
||||
// If avatar URL is provided for assistant, show image
|
||||
if (avatarUrl && normalized === "assistant") {
|
||||
return html`<img class="chat-avatar ${className}" src="${avatarUrl}" alt="Assistant" />`;
|
||||
}
|
||||
|
||||
return html`<div class="chat-avatar ${className}">${initial}</div>`;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { render } from "lit";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { analyzeConfigSchema, renderConfigForm, SECTION_META } from "./views/config-form";
|
||||
import { analyzeConfigSchema, renderConfigForm } from "./views/config-form";
|
||||
|
||||
const rootSchema = {
|
||||
type: "object",
|
||||
@@ -40,10 +40,6 @@ const rootSchema = {
|
||||
};
|
||||
|
||||
describe("config form renderer", () => {
|
||||
it("exposes section metadata", () => {
|
||||
expect(SECTION_META.env.label).toBe("Environment Variables");
|
||||
});
|
||||
|
||||
it("renders inputs and patches values", () => {
|
||||
const onPatch = vi.fn();
|
||||
const container = document.createElement("div");
|
||||
|
||||
@@ -54,7 +54,7 @@ const sectionIcons = {
|
||||
};
|
||||
|
||||
// Section metadata
|
||||
export const SECTION_META: Record<string, { label: string; description: string }> = {
|
||||
const SECTION_META: Record<string, { label: string; description: string }> = {
|
||||
env: { label: "Environment Variables", description: "Environment variables passed to the gateway process" },
|
||||
update: { label: "Updates", description: "Auto-update settings and release channel" },
|
||||
agents: { label: "Agents", description: "Agent configurations, models, and identities" },
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export { renderConfigForm, type ConfigFormProps, SECTION_META } from "./config-form.render";
|
||||
export { renderConfigForm, type ConfigFormProps } from "./config-form.render";
|
||||
export {
|
||||
analyzeConfigSchema,
|
||||
type ConfigSchemaAnalysis,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { html, nothing } from "lit";
|
||||
import type { ConfigUiHints } from "../types";
|
||||
import { analyzeConfigSchema, renderConfigForm, SECTION_META } from "./config-form";
|
||||
import { analyzeConfigSchema, renderConfigForm } from "./config-form";
|
||||
import {
|
||||
hintForPath,
|
||||
humanize,
|
||||
|
||||
Reference in New Issue
Block a user