Merge branch 'main' into main
This commit is contained in:
commit
b9bcb41456
@ -41,6 +41,7 @@ Status: unreleased.
|
||||
- Routing: precompile session key regexes. (#1697) Thanks @Ray0907.
|
||||
- TUI: avoid width overflow when rendering selection lists. (#1686) Thanks @mossein.
|
||||
- Telegram: keep topic IDs in restart sentinel notifications. (#1807) Thanks @hsrvc.
|
||||
- Telegram: add optional silent send flag (disable notifications). (#2382) Thanks @Suksham-sharma.
|
||||
- Config: apply config.env before ${VAR} substitution. (#1813) Thanks @spanishflu-est1918.
|
||||
- Slack: clear ack reaction after streamed replies. (#2044) Thanks @fancyboi999.
|
||||
- macOS: keep custom SSH usernames in remote target. (#2046) Thanks @algal.
|
||||
|
||||
@ -282,22 +282,6 @@ enum CommandResolver {
|
||||
guard !settings.target.isEmpty else { return nil }
|
||||
guard let parsed = self.parseSSHTarget(settings.target) else { return nil }
|
||||
|
||||
var args: [String] = [
|
||||
"-o", "BatchMode=yes",
|
||||
"-o", "StrictHostKeyChecking=accept-new",
|
||||
"-o", "UpdateHostKeys=yes",
|
||||
]
|
||||
if parsed.port > 0 { args.append(contentsOf: ["-p", String(parsed.port)]) }
|
||||
let identity = settings.identity.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !identity.isEmpty {
|
||||
// Only use IdentitiesOnly when an explicit identity file is provided.
|
||||
// This allows 1Password SSH agent and other SSH agents to provide keys.
|
||||
args.append(contentsOf: ["-o", "IdentitiesOnly=yes"])
|
||||
args.append(contentsOf: ["-i", identity])
|
||||
}
|
||||
let userHost = parsed.user.map { "\($0)@\(parsed.host)" } ?? parsed.host
|
||||
args.append(userHost)
|
||||
|
||||
// Run the real clawdbot CLI on the remote host.
|
||||
let exportedPath = [
|
||||
"/opt/homebrew/bin",
|
||||
@ -324,7 +308,7 @@ enum CommandResolver {
|
||||
} else {
|
||||
"""
|
||||
PRJ=\(self.shellQuote(userPRJ))
|
||||
cd \(self.shellQuote(userPRJ)) || { echo "Project root not found: \(userPRJ)"; exit 127; }
|
||||
cd "$PRJ" || { echo "Project root not found: $PRJ"; exit 127; }
|
||||
"""
|
||||
}
|
||||
|
||||
@ -378,7 +362,16 @@ enum CommandResolver {
|
||||
echo "clawdbot CLI missing on remote host"; exit 127;
|
||||
fi
|
||||
"""
|
||||
args.append(contentsOf: ["/bin/sh", "-c", scriptBody])
|
||||
let options: [String] = [
|
||||
"-o", "BatchMode=yes",
|
||||
"-o", "StrictHostKeyChecking=accept-new",
|
||||
"-o", "UpdateHostKeys=yes",
|
||||
]
|
||||
let args = self.sshArguments(
|
||||
target: parsed,
|
||||
identity: settings.identity,
|
||||
options: options,
|
||||
remoteCommand: ["/bin/sh", "-c", scriptBody])
|
||||
return ["/usr/bin/ssh"] + args
|
||||
}
|
||||
|
||||
@ -427,8 +420,11 @@ enum CommandResolver {
|
||||
}
|
||||
|
||||
static func parseSSHTarget(_ target: String) -> SSHParsedTarget? {
|
||||
let trimmed = target.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let trimmed = self.normalizeSSHTargetInput(target)
|
||||
guard !trimmed.isEmpty else { return nil }
|
||||
if trimmed.rangeOfCharacter(from: CharacterSet.whitespacesAndNewlines.union(.controlCharacters)) != nil {
|
||||
return nil
|
||||
}
|
||||
let userHostPort: String
|
||||
let user: String?
|
||||
if let atRange = trimmed.range(of: "@") {
|
||||
@ -444,13 +440,31 @@ enum CommandResolver {
|
||||
if let colon = userHostPort.lastIndex(of: ":"), colon != userHostPort.startIndex {
|
||||
host = String(userHostPort[..<colon])
|
||||
let portStr = String(userHostPort[userHostPort.index(after: colon)...])
|
||||
port = Int(portStr) ?? 22
|
||||
guard let parsedPort = Int(portStr), parsedPort > 0, parsedPort <= 65535 else {
|
||||
return nil
|
||||
}
|
||||
port = parsedPort
|
||||
} else {
|
||||
host = userHostPort
|
||||
port = 22
|
||||
}
|
||||
|
||||
return SSHParsedTarget(user: user, host: host, port: port)
|
||||
return self.makeSSHTarget(user: user, host: host, port: port)
|
||||
}
|
||||
|
||||
static func sshTargetValidationMessage(_ target: String) -> String? {
|
||||
let trimmed = self.normalizeSSHTargetInput(target)
|
||||
guard !trimmed.isEmpty else { return nil }
|
||||
if trimmed.hasPrefix("-") {
|
||||
return "SSH target cannot start with '-'"
|
||||
}
|
||||
if trimmed.rangeOfCharacter(from: CharacterSet.whitespacesAndNewlines.union(.controlCharacters)) != nil {
|
||||
return "SSH target cannot contain spaces"
|
||||
}
|
||||
if self.parseSSHTarget(trimmed) == nil {
|
||||
return "SSH target must look like user@host[:port]"
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func shellQuote(_ text: String) -> String {
|
||||
@ -468,6 +482,64 @@ enum CommandResolver {
|
||||
return URL(fileURLWithPath: expanded)
|
||||
}
|
||||
|
||||
private static func normalizeSSHTargetInput(_ target: String) -> String {
|
||||
var trimmed = target.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmed.hasPrefix("ssh ") {
|
||||
trimmed = trimmed.replacingOccurrences(of: "ssh ", with: "")
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
private static func isValidSSHComponent(_ value: String, allowLeadingDash: Bool = false) -> Bool {
|
||||
if value.isEmpty { return false }
|
||||
if !allowLeadingDash, value.hasPrefix("-") { return false }
|
||||
let invalid = CharacterSet.whitespacesAndNewlines.union(.controlCharacters)
|
||||
return value.rangeOfCharacter(from: invalid) == nil
|
||||
}
|
||||
|
||||
static func makeSSHTarget(user: String?, host: String, port: Int) -> SSHParsedTarget? {
|
||||
let trimmedHost = host.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard self.isValidSSHComponent(trimmedHost) else { return nil }
|
||||
let trimmedUser = user?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let normalizedUser: String?
|
||||
if let trimmedUser {
|
||||
guard self.isValidSSHComponent(trimmedUser) else { return nil }
|
||||
normalizedUser = trimmedUser.isEmpty ? nil : trimmedUser
|
||||
} else {
|
||||
normalizedUser = nil
|
||||
}
|
||||
guard port > 0, port <= 65535 else { return nil }
|
||||
return SSHParsedTarget(user: normalizedUser, host: trimmedHost, port: port)
|
||||
}
|
||||
|
||||
private static func sshTargetString(_ target: SSHParsedTarget) -> String {
|
||||
target.user.map { "\($0)@\(target.host)" } ?? target.host
|
||||
}
|
||||
|
||||
static func sshArguments(
|
||||
target: SSHParsedTarget,
|
||||
identity: String,
|
||||
options: [String],
|
||||
remoteCommand: [String] = []) -> [String]
|
||||
{
|
||||
var args = options
|
||||
if target.port > 0 {
|
||||
args.append(contentsOf: ["-p", String(target.port)])
|
||||
}
|
||||
let trimmedIdentity = identity.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !trimmedIdentity.isEmpty {
|
||||
// Only use IdentitiesOnly when an explicit identity file is provided.
|
||||
// This allows 1Password SSH agent and other SSH agents to provide keys.
|
||||
args.append(contentsOf: ["-o", "IdentitiesOnly=yes"])
|
||||
args.append(contentsOf: ["-i", trimmedIdentity])
|
||||
}
|
||||
args.append("--")
|
||||
args.append(self.sshTargetString(target))
|
||||
args.append(contentsOf: remoteCommand)
|
||||
return args
|
||||
}
|
||||
|
||||
#if SWIFT_PACKAGE
|
||||
static func _testNodeManagerBinPaths(home: URL) -> [String] {
|
||||
self.nodeManagerBinPaths(home: home)
|
||||
|
||||
@ -243,25 +243,36 @@ struct GeneralSettings: View {
|
||||
}
|
||||
|
||||
private var remoteSshRow: some View {
|
||||
HStack(alignment: .center, spacing: 10) {
|
||||
Text("SSH target")
|
||||
.font(.callout.weight(.semibold))
|
||||
.frame(width: self.remoteLabelWidth, alignment: .leading)
|
||||
TextField("user@host[:22]", text: self.$state.remoteTarget)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(maxWidth: .infinity)
|
||||
Button {
|
||||
Task { await self.testRemote() }
|
||||
} label: {
|
||||
if self.remoteStatus == .checking {
|
||||
ProgressView().controlSize(.small)
|
||||
} else {
|
||||
Text("Test remote")
|
||||
let trimmedTarget = self.state.remoteTarget.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let validationMessage = CommandResolver.sshTargetValidationMessage(trimmedTarget)
|
||||
let canTest = !trimmedTarget.isEmpty && validationMessage == nil
|
||||
|
||||
return VStack(alignment: .leading, spacing: 4) {
|
||||
HStack(alignment: .center, spacing: 10) {
|
||||
Text("SSH target")
|
||||
.font(.callout.weight(.semibold))
|
||||
.frame(width: self.remoteLabelWidth, alignment: .leading)
|
||||
TextField("user@host[:22]", text: self.$state.remoteTarget)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(maxWidth: .infinity)
|
||||
Button {
|
||||
Task { await self.testRemote() }
|
||||
} label: {
|
||||
if self.remoteStatus == .checking {
|
||||
ProgressView().controlSize(.small)
|
||||
} else {
|
||||
Text("Test remote")
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(self.remoteStatus == .checking || !canTest)
|
||||
}
|
||||
if let validationMessage {
|
||||
Text(validationMessage)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.red)
|
||||
.padding(.leading, self.remoteLabelWidth + 10)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(self.remoteStatus == .checking || self.state.remoteTarget
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
@ -540,8 +551,15 @@ extension GeneralSettings {
|
||||
}
|
||||
|
||||
// Step 1: basic SSH reachability check
|
||||
guard let sshCommand = Self.sshCheckCommand(
|
||||
target: settings.target,
|
||||
identity: settings.identity)
|
||||
else {
|
||||
self.remoteStatus = .failed("SSH target is invalid")
|
||||
return
|
||||
}
|
||||
let sshResult = await ShellExecutor.run(
|
||||
command: Self.sshCheckCommand(target: settings.target, identity: settings.identity),
|
||||
command: sshCommand,
|
||||
cwd: nil,
|
||||
env: nil,
|
||||
timeout: 8)
|
||||
@ -587,20 +605,20 @@ extension GeneralSettings {
|
||||
return !host.isEmpty
|
||||
}
|
||||
|
||||
private static func sshCheckCommand(target: String, identity: String) -> [String] {
|
||||
var args: [String] = [
|
||||
"/usr/bin/ssh",
|
||||
private static func sshCheckCommand(target: String, identity: String) -> [String]? {
|
||||
guard let parsed = CommandResolver.parseSSHTarget(target) else { return nil }
|
||||
let options = [
|
||||
"-o", "BatchMode=yes",
|
||||
"-o", "ConnectTimeout=5",
|
||||
"-o", "StrictHostKeyChecking=accept-new",
|
||||
"-o", "UpdateHostKeys=yes",
|
||||
]
|
||||
if !identity.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
args.append(contentsOf: ["-i", identity])
|
||||
}
|
||||
args.append(target)
|
||||
args.append("echo ok")
|
||||
return args
|
||||
let args = CommandResolver.sshArguments(
|
||||
target: parsed,
|
||||
identity: identity,
|
||||
options: options,
|
||||
remoteCommand: ["echo", "ok"])
|
||||
return ["/usr/bin/ssh"] + args
|
||||
}
|
||||
|
||||
private func formatSSHFailure(_ response: Response, target: String) -> String {
|
||||
|
||||
@ -559,22 +559,21 @@ final class NodePairingApprovalPrompter {
|
||||
let process = Process()
|
||||
process.executableURL = URL(fileURLWithPath: "/usr/bin/ssh")
|
||||
|
||||
var args = [
|
||||
"-o",
|
||||
"BatchMode=yes",
|
||||
"-o",
|
||||
"ConnectTimeout=5",
|
||||
"-o",
|
||||
"NumberOfPasswordPrompts=0",
|
||||
"-o",
|
||||
"PreferredAuthentications=publickey",
|
||||
"-o",
|
||||
"StrictHostKeyChecking=accept-new",
|
||||
let options = [
|
||||
"-o", "BatchMode=yes",
|
||||
"-o", "ConnectTimeout=5",
|
||||
"-o", "NumberOfPasswordPrompts=0",
|
||||
"-o", "PreferredAuthentications=publickey",
|
||||
"-o", "StrictHostKeyChecking=accept-new",
|
||||
]
|
||||
if port > 0, port != 22 {
|
||||
args.append(contentsOf: ["-p", String(port)])
|
||||
guard let target = CommandResolver.makeSSHTarget(user: user, host: host, port: port) else {
|
||||
return false
|
||||
}
|
||||
args.append(contentsOf: ["-l", user, host, "/usr/bin/true"])
|
||||
let args = CommandResolver.sshArguments(
|
||||
target: target,
|
||||
identity: "",
|
||||
options: options,
|
||||
remoteCommand: ["/usr/bin/true"])
|
||||
process.arguments = args
|
||||
let pipe = Pipe()
|
||||
process.standardOutput = pipe
|
||||
|
||||
@ -206,6 +206,16 @@ extension OnboardingView {
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: fieldWidth)
|
||||
}
|
||||
if let message = CommandResolver.sshTargetValidationMessage(self.state.remoteTarget) {
|
||||
GridRow {
|
||||
Text("")
|
||||
.frame(width: labelWidth, alignment: .leading)
|
||||
Text(message)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.red)
|
||||
.frame(width: fieldWidth, alignment: .leading)
|
||||
}
|
||||
}
|
||||
GridRow {
|
||||
Text("Identity file")
|
||||
.font(.callout.weight(.semibold))
|
||||
|
||||
@ -70,7 +70,7 @@ final class RemotePortTunnel {
|
||||
"ssh tunnel using default remote port " +
|
||||
"host=\(sshHost, privacy: .public) port=\(remotePort, privacy: .public)")
|
||||
}
|
||||
var args: [String] = [
|
||||
let options: [String] = [
|
||||
"-o", "BatchMode=yes",
|
||||
"-o", "ExitOnForwardFailure=yes",
|
||||
"-o", "StrictHostKeyChecking=accept-new",
|
||||
@ -81,16 +81,11 @@ final class RemotePortTunnel {
|
||||
"-N",
|
||||
"-L", "\(localPort):127.0.0.1:\(resolvedRemotePort)",
|
||||
]
|
||||
if parsed.port > 0 { args.append(contentsOf: ["-p", String(parsed.port)]) }
|
||||
let identity = settings.identity.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !identity.isEmpty {
|
||||
// Only use IdentitiesOnly when an explicit identity file is provided.
|
||||
// This allows 1Password SSH agent and other SSH agents to provide keys.
|
||||
args.append(contentsOf: ["-o", "IdentitiesOnly=yes"])
|
||||
args.append(contentsOf: ["-i", identity])
|
||||
}
|
||||
let userHost = parsed.user.map { "\($0)@\(parsed.host)" } ?? parsed.host
|
||||
args.append(userHost)
|
||||
let args = CommandResolver.sshArguments(
|
||||
target: parsed,
|
||||
identity: identity,
|
||||
options: options)
|
||||
|
||||
let process = Process()
|
||||
process.executableURL = URL(fileURLWithPath: "/usr/bin/ssh")
|
||||
|
||||
@ -123,11 +123,16 @@ import Testing
|
||||
configRoot: [:])
|
||||
|
||||
#expect(cmd.first == "/usr/bin/ssh")
|
||||
#expect(cmd.contains("clawd@example.com"))
|
||||
if let marker = cmd.firstIndex(of: "--") {
|
||||
#expect(cmd[marker + 1] == "clawd@example.com")
|
||||
} else {
|
||||
#expect(Bool(false))
|
||||
}
|
||||
#expect(cmd.contains("-i"))
|
||||
#expect(cmd.contains("/tmp/id_ed25519"))
|
||||
if let script = cmd.last {
|
||||
#expect(script.contains("cd '/srv/clawdbot'"))
|
||||
#expect(script.contains("PRJ='/srv/clawdbot'"))
|
||||
#expect(script.contains("cd \"$PRJ\""))
|
||||
#expect(script.contains("clawdbot"))
|
||||
#expect(script.contains("status"))
|
||||
#expect(script.contains("--json"))
|
||||
@ -135,6 +140,12 @@ import Testing
|
||||
}
|
||||
}
|
||||
|
||||
@Test func rejectsUnsafeSSHTargets() async throws {
|
||||
#expect(CommandResolver.parseSSHTarget("-oProxyCommand=calc") == nil)
|
||||
#expect(CommandResolver.parseSSHTarget("host:-oProxyCommand=calc") == nil)
|
||||
#expect(CommandResolver.parseSSHTarget("user@host:2222")?.port == 2222)
|
||||
}
|
||||
|
||||
@Test func configRootLocalOverridesRemoteDefaults() async throws {
|
||||
let defaults = self.makeDefaults()
|
||||
defaults.set(AppState.ConnectionMode.remote.rawValue, forKey: connectionModeKey)
|
||||
|
||||
@ -11,7 +11,12 @@ struct MasterDiscoveryMenuSmokeTests {
|
||||
discovery.statusText = "Searching…"
|
||||
discovery.gateways = []
|
||||
|
||||
let view = GatewayDiscoveryInlineList(discovery: discovery, currentTarget: nil, onSelect: { _ in })
|
||||
let view = GatewayDiscoveryInlineList(
|
||||
discovery: discovery,
|
||||
currentTarget: nil,
|
||||
currentUrl: nil,
|
||||
transport: .ssh,
|
||||
onSelect: { _ in })
|
||||
_ = view.body
|
||||
}
|
||||
|
||||
@ -32,7 +37,12 @@ struct MasterDiscoveryMenuSmokeTests {
|
||||
]
|
||||
|
||||
let currentTarget = "\(NSUserName())@office.tailnet-123.ts.net:2222"
|
||||
let view = GatewayDiscoveryInlineList(discovery: discovery, currentTarget: currentTarget, onSelect: { _ in })
|
||||
let view = GatewayDiscoveryInlineList(
|
||||
discovery: discovery,
|
||||
currentTarget: currentTarget,
|
||||
currentUrl: nil,
|
||||
transport: .ssh,
|
||||
onSelect: { _ in })
|
||||
_ = view.body
|
||||
}
|
||||
|
||||
|
||||
@ -59,6 +59,7 @@ function buildSendSchema(options: { includeButtons: boolean; includeCards: boole
|
||||
replyTo: Type.Optional(Type.String()),
|
||||
threadId: Type.Optional(Type.String()),
|
||||
asVoice: Type.Optional(Type.Boolean()),
|
||||
silent: Type.Optional(Type.Boolean()),
|
||||
bestEffort: Type.Optional(Type.Boolean()),
|
||||
gifPlayback: Type.Optional(Type.Boolean()),
|
||||
buttons: Type.Optional(
|
||||
|
||||
@ -176,6 +176,7 @@ export async function handleTelegramAction(
|
||||
replyToMessageId: replyToMessageId ?? undefined,
|
||||
messageThreadId: messageThreadId ?? undefined,
|
||||
asVoice: typeof params.asVoice === "boolean" ? params.asVoice : undefined,
|
||||
silent: typeof params.silent === "boolean" ? params.silent : undefined,
|
||||
});
|
||||
return jsonResult({
|
||||
ok: true,
|
||||
|
||||
@ -36,4 +36,30 @@ describe("telegramMessageActions", () => {
|
||||
cfg,
|
||||
);
|
||||
});
|
||||
|
||||
it("passes silent flag for silent sends", async () => {
|
||||
handleTelegramAction.mockClear();
|
||||
const cfg = { channels: { telegram: { botToken: "tok" } } } as ClawdbotConfig;
|
||||
|
||||
await telegramMessageActions.handleAction({
|
||||
action: "send",
|
||||
params: {
|
||||
to: "456",
|
||||
message: "Silent notification test",
|
||||
silent: true,
|
||||
},
|
||||
cfg,
|
||||
accountId: undefined,
|
||||
});
|
||||
|
||||
expect(handleTelegramAction).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: "sendMessage",
|
||||
to: "456",
|
||||
content: "Silent notification test",
|
||||
silent: true,
|
||||
}),
|
||||
cfg,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@ -20,6 +20,7 @@ function readTelegramSendParams(params: Record<string, unknown>) {
|
||||
const threadId = readStringParam(params, "threadId");
|
||||
const buttons = params.buttons;
|
||||
const asVoice = typeof params.asVoice === "boolean" ? params.asVoice : undefined;
|
||||
const silent = typeof params.silent === "boolean" ? params.silent : undefined;
|
||||
return {
|
||||
to,
|
||||
content,
|
||||
@ -28,6 +29,7 @@ function readTelegramSendParams(params: Record<string, unknown>) {
|
||||
messageThreadId: threadId ?? undefined,
|
||||
buttons,
|
||||
asVoice,
|
||||
silent,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -22,7 +22,8 @@ export function registerMessageSendCommand(message: Command, helpers: MessageCli
|
||||
.option("--card <json>", "Adaptive Card JSON object (when supported by the channel)")
|
||||
.option("--reply-to <id>", "Reply-to message id")
|
||||
.option("--thread-id <id>", "Thread id (Telegram forum thread)")
|
||||
.option("--gif-playback", "Treat video media as GIF playback (WhatsApp only).", false),
|
||||
.option("--gif-playback", "Treat video media as GIF playback (WhatsApp only).", false)
|
||||
.option("--silent", "Send message silently without notification (Telegram only)", false),
|
||||
)
|
||||
.action(async (opts) => {
|
||||
await helpers.runMessageAction("send", opts);
|
||||
|
||||
@ -159,7 +159,8 @@ export const ToolPolicySchema = ToolPolicyBaseSchema.superRefine((value, ctx) =>
|
||||
if (value.allow && value.allow.length > 0 && value.alsoAllow && value.alsoAllow.length > 0) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "tools policy cannot set both allow and alsoAllow in the same scope (merge alsoAllow into allow, or remove allow and use profile + alsoAllow)",
|
||||
message:
|
||||
"tools policy cannot set both allow and alsoAllow in the same scope (merge alsoAllow into allow, or remove allow and use profile + alsoAllow)",
|
||||
});
|
||||
}
|
||||
}).optional();
|
||||
|
||||
@ -1,10 +1,13 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
import { installGatewayTestHooks, getFreePort, startGatewayServer } from "./test-helpers.server.js";
|
||||
import { resetTestPluginRegistry, setTestPluginRegistry, testState } from "./test-helpers.mocks.js";
|
||||
import { createTestRegistry } from "../test-utils/channel-plugins.js";
|
||||
import { CONFIG_PATH_CLAWDBOT } from "../config/config.js";
|
||||
|
||||
installGatewayTestHooks({ scope: "suite" });
|
||||
|
||||
@ -97,10 +100,11 @@ describe("POST /tools/invoke", () => {
|
||||
|
||||
const port = await getFreePort();
|
||||
const server = await startGatewayServer(port, { bind: "loopback" });
|
||||
const token = resolveGatewayToken();
|
||||
|
||||
const res = await fetch(`http://127.0.0.1:${port}/tools/invoke`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
|
||||
body: JSON.stringify({ tool: "sessions_list", action: "json", args: {}, sessionKey: "main" }),
|
||||
});
|
||||
|
||||
|
||||
@ -476,6 +476,28 @@ describe("sendMessageTelegram", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("sets disable_notification when silent is true", async () => {
|
||||
const chatId = "123";
|
||||
const sendMessage = vi.fn().mockResolvedValue({
|
||||
message_id: 1,
|
||||
chat: { id: chatId },
|
||||
});
|
||||
const api = { sendMessage } as unknown as {
|
||||
sendMessage: typeof sendMessage;
|
||||
};
|
||||
|
||||
await sendMessageTelegram(chatId, "hi", {
|
||||
token: "tok",
|
||||
api,
|
||||
silent: true,
|
||||
});
|
||||
|
||||
expect(sendMessage).toHaveBeenCalledWith(chatId, "hi", {
|
||||
parse_mode: "HTML",
|
||||
disable_notification: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("parses message_thread_id from recipient string (telegram:group:...:topic:...)", async () => {
|
||||
const chatId = "-1001234567890";
|
||||
const sendMessage = vi.fn().mockResolvedValue({
|
||||
|
||||
@ -40,6 +40,8 @@ type TelegramSendOpts = {
|
||||
plainText?: string;
|
||||
/** Send audio as voice message (voice bubble) instead of audio file. Defaults to false. */
|
||||
asVoice?: boolean;
|
||||
/** Send message silently (no notification). Defaults to false. */
|
||||
silent?: boolean;
|
||||
/** Message ID to reply to (for threading) */
|
||||
replyToMessageId?: number;
|
||||
/** Forum topic thread ID (for forum supergroups) */
|
||||
@ -245,6 +247,7 @@ export async function sendMessageTelegram(
|
||||
const sendParams = {
|
||||
parse_mode: "HTML" as const,
|
||||
...baseParams,
|
||||
...(opts.silent === true ? { disable_notification: true } : {}),
|
||||
};
|
||||
const res = await requestWithDiag(
|
||||
() => api.sendMessage(chatId, htmlText, sendParams),
|
||||
@ -298,6 +301,7 @@ export async function sendMessageTelegram(
|
||||
caption: htmlCaption,
|
||||
...(htmlCaption ? { parse_mode: "HTML" as const } : {}),
|
||||
...baseMediaParams,
|
||||
...(opts.silent === true ? { disable_notification: true } : {}),
|
||||
};
|
||||
let result:
|
||||
| Awaited<ReturnType<typeof api.sendPhoto>>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user