Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0cbc051019 | ||
|
|
1005934964 | ||
|
|
76014685eb | ||
|
|
875b018ea1 | ||
|
|
b6581e77f6 | ||
|
|
81e915110e | ||
|
|
7e9aa3c275 | ||
|
|
65e2d939e1 | ||
|
|
67db63ba05 | ||
|
|
bbefb2e5a5 | ||
|
|
50f233d16d | ||
|
|
48aea87028 |
No files matched your search
+1
-26
@@ -146,22 +146,6 @@
|
||||
}
|
||||
],
|
||||
"results": {
|
||||
".detect-secrets.cfg": [
|
||||
{
|
||||
"type": "Private Key",
|
||||
"filename": ".detect-secrets.cfg",
|
||||
"hashed_secret": "1348b145fa1a555461c1b790a2f66614781091e9",
|
||||
"is_verified": false,
|
||||
"line_number": 13
|
||||
},
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": ".detect-secrets.cfg",
|
||||
"hashed_secret": "fe88fceb47e040ba1bfafa4ac639366188df2f6d",
|
||||
"is_verified": false,
|
||||
"line_number": 15
|
||||
}
|
||||
],
|
||||
".env.example": [
|
||||
{
|
||||
"type": "Twilio API Key",
|
||||
@@ -246,15 +230,6 @@
|
||||
"line_number": 100
|
||||
}
|
||||
],
|
||||
"dist/control-ui/assets/index-DsXRcnEw.js": [
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "dist/control-ui/assets/index-DsXRcnEw.js",
|
||||
"hashed_secret": "ecb252044b5ea0f679ee78ec1a12904739e2904d",
|
||||
"is_verified": false,
|
||||
"line_number": 16
|
||||
}
|
||||
],
|
||||
"docs/brave-search.md": [
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
@@ -2212,5 +2187,5 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"generated_at": "2026-01-25T10:46:40Z"
|
||||
"generated_at": "2026-01-25T10:55:04Z"
|
||||
}
|
||||
@@ -36,6 +36,7 @@ Docs: https://docs.clawd.bot
|
||||
- TUI: reload history after gateway reconnect to restore session state. (#1663)
|
||||
- Telegram: use wrapped fetch for long-polling on Node to normalize AbortSignal handling. (#1639)
|
||||
- Telegram: set fetch duplex="half" for uploads on Node 22 to avoid sendPhoto failures. (#1684) Thanks @commdata2338.
|
||||
- Telegram: honor per-account proxy for outbound API calls. (#1774) Thanks @radek-paclt.
|
||||
- Signal: repair reaction sends (group/UUID targets + CLI author flags). (#1651) Thanks @vilkasdev.
|
||||
- Signal: add configurable signal-cli startup timeout + external daemon mode docs. (#1677) https://docs.clawd.bot/channels/signal
|
||||
- Exec: keep approvals for elevated ask unless full mode. (#1616) Thanks @ivancasco.
|
||||
@@ -50,6 +51,7 @@ Docs: https://docs.clawd.bot
|
||||
- Gateway: store lock files in the temp directory to avoid stale locks on persistent volumes. (#1676)
|
||||
- macOS: default direct-transport `ws://` URLs to port 18789; document `gateway.remote.transport`. (#1603) Thanks @ngutman.
|
||||
- Voice Call: return stream TwiML for outbound conversation calls on initial Twilio webhook. (#1634)
|
||||
- Voice Call: serialize Twilio TTS playback and cancel on barge-in to prevent overlap. (#1713) Thanks @dguido.
|
||||
- Google Chat: tighten email allowlist matching, typing cleanup, media caps, and onboarding/docs/tests. (#1635) Thanks @iHildy.
|
||||
- Google Chat: normalize space targets without double `spaces/` prefix.
|
||||
- Messaging: keep newline chunking safe for fenced markdown blocks across channels.
|
||||
|
||||
@@ -574,46 +574,22 @@ public actor GatewayChannelActor {
|
||||
params: [String: AnyCodable]?,
|
||||
timeoutMs: Double? = nil) async throws -> Data
|
||||
{
|
||||
do {
|
||||
try await self.connect()
|
||||
} catch {
|
||||
throw self.wrap(error, context: "gateway connect")
|
||||
}
|
||||
let id = UUID().uuidString
|
||||
try await self.connectOrThrow(context: "gateway connect")
|
||||
let effectiveTimeout = timeoutMs ?? self.defaultRequestTimeoutMs
|
||||
// Encode request using the generated models to avoid JSONSerialization/ObjC bridging pitfalls.
|
||||
let paramsObject: ProtoAnyCodable? = params.map { entries in
|
||||
let dict = entries.reduce(into: [String: ProtoAnyCodable]()) { dict, entry in
|
||||
dict[entry.key] = ProtoAnyCodable(entry.value.value)
|
||||
}
|
||||
return ProtoAnyCodable(dict)
|
||||
}
|
||||
let frame = RequestFrame(
|
||||
type: "req",
|
||||
id: id,
|
||||
method: method,
|
||||
params: paramsObject)
|
||||
let data: Data
|
||||
do {
|
||||
data = try self.encoder.encode(frame)
|
||||
} catch {
|
||||
self.logger.error(
|
||||
"gateway request encode failed \(method, privacy: .public) error=\(error.localizedDescription, privacy: .public)")
|
||||
throw error
|
||||
}
|
||||
let payload = try self.encodeRequest(method: method, params: params, kind: "request")
|
||||
let response = try await withCheckedThrowingContinuation { (cont: CheckedContinuation<GatewayFrame, Error>) in
|
||||
self.pending[id] = cont
|
||||
self.pending[payload.id] = cont
|
||||
Task { [weak self] in
|
||||
guard let self else { return }
|
||||
try? await Task.sleep(nanoseconds: UInt64(effectiveTimeout * 1_000_000))
|
||||
await self.timeoutRequest(id: id, timeoutMs: effectiveTimeout)
|
||||
await self.timeoutRequest(id: payload.id, timeoutMs: effectiveTimeout)
|
||||
}
|
||||
Task {
|
||||
do {
|
||||
try await self.task?.send(.data(data))
|
||||
try await self.task?.send(.data(payload.data))
|
||||
} catch {
|
||||
let wrapped = self.wrap(error, context: "gateway send \(method)")
|
||||
let waiter = self.pending.removeValue(forKey: id)
|
||||
let waiter = self.pending.removeValue(forKey: payload.id)
|
||||
// Treat send failures as a broken socket: mark disconnected and trigger reconnect.
|
||||
self.connected = false
|
||||
self.task?.cancel(with: .goingAway, reason: nil)
|
||||
@@ -643,6 +619,29 @@ public actor GatewayChannelActor {
|
||||
return Data() // Should not happen, but tolerate empty payloads.
|
||||
}
|
||||
|
||||
public func send(method: String, params: [String: AnyCodable]?) async throws {
|
||||
try await self.connectOrThrow(context: "gateway connect")
|
||||
let payload = try self.encodeRequest(method: method, params: params, kind: "send")
|
||||
guard let task = self.task else {
|
||||
throw NSError(
|
||||
domain: "Gateway",
|
||||
code: 5,
|
||||
userInfo: [NSLocalizedDescriptionKey: "gateway socket unavailable"])
|
||||
}
|
||||
do {
|
||||
try await task.send(.data(payload.data))
|
||||
} catch {
|
||||
let wrapped = self.wrap(error, context: "gateway send \(method)")
|
||||
self.connected = false
|
||||
self.task?.cancel(with: .goingAway, reason: nil)
|
||||
Task { [weak self] in
|
||||
guard let self else { return }
|
||||
await self.scheduleReconnect()
|
||||
}
|
||||
throw wrapped
|
||||
}
|
||||
}
|
||||
|
||||
// Wrap low-level URLSession/WebSocket errors with context so UI can surface them.
|
||||
private func wrap(_ error: Error, context: String) -> Error {
|
||||
if let urlError = error as? URLError {
|
||||
@@ -657,6 +656,42 @@ public actor GatewayChannelActor {
|
||||
return NSError(domain: ns.domain, code: ns.code, userInfo: [NSLocalizedDescriptionKey: "\(context): \(desc)"])
|
||||
}
|
||||
|
||||
private func connectOrThrow(context: String) async throws {
|
||||
do {
|
||||
try await self.connect()
|
||||
} catch {
|
||||
throw self.wrap(error, context: context)
|
||||
}
|
||||
}
|
||||
|
||||
private func encodeRequest(
|
||||
method: String,
|
||||
params: [String: AnyCodable]?,
|
||||
kind: String) throws -> (id: String, data: Data)
|
||||
{
|
||||
let id = UUID().uuidString
|
||||
// Encode request using the generated models to avoid JSONSerialization/ObjC bridging pitfalls.
|
||||
let paramsObject: ProtoAnyCodable? = params.map { entries in
|
||||
let dict = entries.reduce(into: [String: ProtoAnyCodable]()) { dict, entry in
|
||||
dict[entry.key] = ProtoAnyCodable(entry.value.value)
|
||||
}
|
||||
return ProtoAnyCodable(dict)
|
||||
}
|
||||
let frame = RequestFrame(
|
||||
type: "req",
|
||||
id: id,
|
||||
method: method,
|
||||
params: paramsObject)
|
||||
do {
|
||||
let data = try self.encoder.encode(frame)
|
||||
return (id: id, data: data)
|
||||
} catch {
|
||||
self.logger.error(
|
||||
"gateway \(kind) encode failed \(method, privacy: .public) error=\(error.localizedDescription, privacy: .public)")
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private func failPending(_ error: Error) async {
|
||||
let waiters = self.pending
|
||||
self.pending.removeAll()
|
||||
|
||||
@@ -143,7 +143,7 @@ public actor GatewayNodeSession {
|
||||
"payloadJSON": AnyCodable(payloadJSON ?? NSNull()),
|
||||
]
|
||||
do {
|
||||
_ = try await channel.request(method: "node.event", params: params, timeoutMs: 8000)
|
||||
try await channel.send(method: "node.event", params: params)
|
||||
} catch {
|
||||
self.logger.error("node event failed: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
@@ -224,7 +224,7 @@ public actor GatewayNodeSession {
|
||||
])
|
||||
}
|
||||
do {
|
||||
_ = try await channel.request(method: "node.invoke.result", params: params, timeoutMs: 15000)
|
||||
try await channel.send(method: "node.invoke.result", params: params)
|
||||
} catch {
|
||||
self.logger.error("node invoke result failed: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
|
||||
@@ -138,6 +138,7 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS,
|
||||
- [Can I use self-hosted models (llama.cpp, vLLM, Ollama)?](#can-i-use-selfhosted-models-llamacpp-vllm-ollama)
|
||||
- [What do Clawd, Flawd, and Krill use for models?](#what-do-clawd-flawd-and-krill-use-for-models)
|
||||
- [How do I switch models on the fly (without restarting)?](#how-do-i-switch-models-on-the-fly-without-restarting)
|
||||
- [Can I use GPT 5.2 for daily tasks and Codex 5.2 for coding](#can-i-use-gpt-52-for-daily-tasks-and-codex-52-for-coding)
|
||||
- [Why do I see “Model … is not allowed” and then no reply?](#why-do-i-see-model-is-not-allowed-and-then-no-reply)
|
||||
- [Why do I see “Unknown model: minimax/MiniMax-M2.1”?](#why-do-i-see-unknown-model-minimaxminimaxm21)
|
||||
- [Can I use MiniMax as my default and OpenAI for complex tasks?](#can-i-use-minimax-as-my-default-and-openai-for-complex-tasks)
|
||||
@@ -1947,6 +1948,16 @@ Re-run `/model` **without** the `@profile` suffix:
|
||||
If you want to return to the default, pick it from `/model` (or send `/model <default provider/model>`).
|
||||
Use `/model status` to confirm which auth profile is active.
|
||||
|
||||
### Can I use GPT 5.2 for daily tasks and Codex 5.2 for coding
|
||||
|
||||
Yes. Set one as default and switch as needed:
|
||||
|
||||
- **Quick switch (per session):** `/model gpt-5.2` for daily tasks, `/model gpt-5.2-codex` for coding.
|
||||
- **Default + switch:** set `agents.defaults.model.primary` to `openai-codex/gpt-5.2`, then switch to `openai-codex/gpt-5.2-codex` when coding (or the other way around).
|
||||
- **Sub-agents:** route coding tasks to sub-agents with a different default model.
|
||||
|
||||
See [Models](/concepts/models) and [Slash commands](/tools/slash-commands).
|
||||
|
||||
### Why do I see Model is not allowed and then no reply
|
||||
|
||||
If `agents.defaults.models` is set, it becomes the **allowlist** for `/model` and any
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type {
|
||||
OpenAIRealtimeSTTProvider,
|
||||
RealtimeSTTSession,
|
||||
} from "./providers/stt-openai-realtime.js";
|
||||
import { MediaStreamHandler } from "./media-stream.js";
|
||||
|
||||
const createStubSession = (): RealtimeSTTSession => ({
|
||||
connect: async () => {},
|
||||
sendAudio: () => {},
|
||||
waitForTranscript: async () => "",
|
||||
onPartial: () => {},
|
||||
onTranscript: () => {},
|
||||
onSpeechStart: () => {},
|
||||
close: () => {},
|
||||
isConnected: () => true,
|
||||
});
|
||||
|
||||
const createStubSttProvider = (): OpenAIRealtimeSTTProvider =>
|
||||
({
|
||||
createSession: () => createStubSession(),
|
||||
}) as unknown as OpenAIRealtimeSTTProvider;
|
||||
|
||||
const flush = async (): Promise<void> => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
};
|
||||
|
||||
const waitForAbort = (signal: AbortSignal): Promise<void> =>
|
||||
new Promise((resolve) => {
|
||||
if (signal.aborted) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
signal.addEventListener("abort", () => resolve(), { once: true });
|
||||
});
|
||||
|
||||
describe("MediaStreamHandler TTS queue", () => {
|
||||
it("serializes TTS playback and resolves in order", async () => {
|
||||
const handler = new MediaStreamHandler({
|
||||
sttProvider: createStubSttProvider(),
|
||||
});
|
||||
const started: number[] = [];
|
||||
const finished: number[] = [];
|
||||
|
||||
let resolveFirst!: () => void;
|
||||
const firstGate = new Promise<void>((resolve) => {
|
||||
resolveFirst = resolve;
|
||||
});
|
||||
|
||||
const first = handler.queueTts("stream-1", async () => {
|
||||
started.push(1);
|
||||
await firstGate;
|
||||
finished.push(1);
|
||||
});
|
||||
const second = handler.queueTts("stream-1", async () => {
|
||||
started.push(2);
|
||||
finished.push(2);
|
||||
});
|
||||
|
||||
await flush();
|
||||
expect(started).toEqual([1]);
|
||||
|
||||
resolveFirst();
|
||||
await first;
|
||||
await second;
|
||||
|
||||
expect(started).toEqual([1, 2]);
|
||||
expect(finished).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it("cancels active playback and clears queued items", async () => {
|
||||
const handler = new MediaStreamHandler({
|
||||
sttProvider: createStubSttProvider(),
|
||||
});
|
||||
|
||||
let queuedRan = false;
|
||||
const started: string[] = [];
|
||||
|
||||
const active = handler.queueTts("stream-1", async (signal) => {
|
||||
started.push("active");
|
||||
await waitForAbort(signal);
|
||||
});
|
||||
void handler.queueTts("stream-1", async () => {
|
||||
queuedRan = true;
|
||||
});
|
||||
|
||||
await flush();
|
||||
expect(started).toEqual(["active"]);
|
||||
|
||||
handler.clearTtsQueue("stream-1");
|
||||
await active;
|
||||
await flush();
|
||||
|
||||
expect(queuedRan).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -29,6 +29,8 @@ export interface MediaStreamConfig {
|
||||
onPartialTranscript?: (callId: string, partial: string) => void;
|
||||
/** Callback when stream connects */
|
||||
onConnect?: (callId: string, streamSid: string) => void;
|
||||
/** Callback when speech starts (barge-in) */
|
||||
onSpeechStart?: (callId: string) => void;
|
||||
/** Callback when stream disconnects */
|
||||
onDisconnect?: (callId: string) => void;
|
||||
}
|
||||
@@ -43,6 +45,13 @@ interface StreamSession {
|
||||
sttSession: RealtimeSTTSession;
|
||||
}
|
||||
|
||||
type TtsQueueEntry = {
|
||||
playFn: (signal: AbortSignal) => Promise<void>;
|
||||
controller: AbortController;
|
||||
resolve: () => void;
|
||||
reject: (error: unknown) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Manages WebSocket connections for Twilio media streams.
|
||||
*/
|
||||
@@ -50,6 +59,12 @@ export class MediaStreamHandler {
|
||||
private wss: WebSocketServer | null = null;
|
||||
private sessions = new Map<string, StreamSession>();
|
||||
private config: MediaStreamConfig;
|
||||
/** TTS playback queues per stream (serialize audio to prevent overlap) */
|
||||
private ttsQueues = new Map<string, TtsQueueEntry[]>();
|
||||
/** Whether TTS is currently playing per stream */
|
||||
private ttsPlaying = new Map<string, boolean>();
|
||||
/** Active TTS playback controllers per stream */
|
||||
private ttsActiveControllers = new Map<string, AbortController>();
|
||||
|
||||
constructor(config: MediaStreamConfig) {
|
||||
this.config = config;
|
||||
@@ -148,6 +163,10 @@ export class MediaStreamHandler {
|
||||
this.config.onTranscript?.(callSid, transcript);
|
||||
});
|
||||
|
||||
sttSession.onSpeechStart(() => {
|
||||
this.config.onSpeechStart?.(callSid);
|
||||
});
|
||||
|
||||
const session: StreamSession = {
|
||||
callId: callSid,
|
||||
streamSid,
|
||||
@@ -177,6 +196,7 @@ export class MediaStreamHandler {
|
||||
private handleStop(session: StreamSession): void {
|
||||
console.log(`[MediaStream] Stream stopped: ${session.streamSid}`);
|
||||
|
||||
this.clearTtsState(session.streamSid);
|
||||
session.sttSession.close();
|
||||
this.sessions.delete(session.streamSid);
|
||||
this.config.onDisconnect?.(session.callId);
|
||||
@@ -228,6 +248,46 @@ export class MediaStreamHandler {
|
||||
this.sendToStream(streamSid, { event: "clear", streamSid });
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue a TTS operation for sequential playback.
|
||||
* Only one TTS operation plays at a time per stream to prevent overlap.
|
||||
*/
|
||||
async queueTts(
|
||||
streamSid: string,
|
||||
playFn: (signal: AbortSignal) => Promise<void>,
|
||||
): Promise<void> {
|
||||
const queue = this.getTtsQueue(streamSid);
|
||||
let resolveEntry: () => void;
|
||||
let rejectEntry: (error: unknown) => void;
|
||||
const promise = new Promise<void>((resolve, reject) => {
|
||||
resolveEntry = resolve;
|
||||
rejectEntry = reject;
|
||||
});
|
||||
|
||||
queue.push({
|
||||
playFn,
|
||||
controller: new AbortController(),
|
||||
resolve: resolveEntry!,
|
||||
reject: rejectEntry!,
|
||||
});
|
||||
|
||||
if (!this.ttsPlaying.get(streamSid)) {
|
||||
void this.processQueue(streamSid);
|
||||
}
|
||||
|
||||
return promise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear TTS queue and interrupt current playback (barge-in).
|
||||
*/
|
||||
clearTtsQueue(streamSid: string): void {
|
||||
const queue = this.getTtsQueue(streamSid);
|
||||
queue.length = 0;
|
||||
this.ttsActiveControllers.get(streamSid)?.abort();
|
||||
this.clearAudio(streamSid);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get active session by call ID.
|
||||
*/
|
||||
@@ -242,11 +302,65 @@ export class MediaStreamHandler {
|
||||
*/
|
||||
closeAll(): void {
|
||||
for (const session of this.sessions.values()) {
|
||||
this.clearTtsState(session.streamSid);
|
||||
session.sttSession.close();
|
||||
session.ws.close();
|
||||
}
|
||||
this.sessions.clear();
|
||||
}
|
||||
|
||||
private getTtsQueue(streamSid: string): TtsQueueEntry[] {
|
||||
const existing = this.ttsQueues.get(streamSid);
|
||||
if (existing) return existing;
|
||||
const queue: TtsQueueEntry[] = [];
|
||||
this.ttsQueues.set(streamSid, queue);
|
||||
return queue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process the TTS queue for a stream.
|
||||
* Uses iterative approach to avoid stack accumulation from recursion.
|
||||
*/
|
||||
private async processQueue(streamSid: string): Promise<void> {
|
||||
this.ttsPlaying.set(streamSid, true);
|
||||
|
||||
while (true) {
|
||||
const queue = this.ttsQueues.get(streamSid);
|
||||
if (!queue || queue.length === 0) {
|
||||
this.ttsPlaying.set(streamSid, false);
|
||||
this.ttsActiveControllers.delete(streamSid);
|
||||
return;
|
||||
}
|
||||
|
||||
const entry = queue.shift()!;
|
||||
this.ttsActiveControllers.set(streamSid, entry.controller);
|
||||
|
||||
try {
|
||||
await entry.playFn(entry.controller.signal);
|
||||
entry.resolve();
|
||||
} catch (error) {
|
||||
if (entry.controller.signal.aborted) {
|
||||
entry.resolve();
|
||||
} else {
|
||||
console.error("[MediaStream] TTS playback error:", error);
|
||||
entry.reject(error);
|
||||
}
|
||||
} finally {
|
||||
if (this.ttsActiveControllers.get(streamSid) === entry.controller) {
|
||||
this.ttsActiveControllers.delete(streamSid);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private clearTtsState(streamSid: string): void {
|
||||
const queue = this.ttsQueues.get(streamSid);
|
||||
if (queue) queue.length = 0;
|
||||
this.ttsActiveControllers.get(streamSid)?.abort();
|
||||
this.ttsActiveControllers.delete(streamSid);
|
||||
this.ttsPlaying.delete(streamSid);
|
||||
this.ttsQueues.delete(streamSid);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -38,6 +38,8 @@ export interface RealtimeSTTSession {
|
||||
onPartial(callback: (partial: string) => void): void;
|
||||
/** Set callback for final transcripts */
|
||||
onTranscript(callback: (transcript: string) => void): void;
|
||||
/** Set callback when speech starts (VAD) */
|
||||
onSpeechStart(callback: () => void): void;
|
||||
/** Close the session */
|
||||
close(): void;
|
||||
/** Check if session is connected */
|
||||
@@ -91,6 +93,7 @@ class OpenAIRealtimeSTTSession implements RealtimeSTTSession {
|
||||
private pendingTranscript = "";
|
||||
private onTranscriptCallback: ((transcript: string) => void) | null = null;
|
||||
private onPartialCallback: ((partial: string) => void) | null = null;
|
||||
private onSpeechStartCallback: (() => void) | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly apiKey: string,
|
||||
@@ -243,6 +246,7 @@ class OpenAIRealtimeSTTSession implements RealtimeSTTSession {
|
||||
case "input_audio_buffer.speech_started":
|
||||
console.log("[RealtimeSTT] Speech started");
|
||||
this.pendingTranscript = "";
|
||||
this.onSpeechStartCallback?.();
|
||||
break;
|
||||
|
||||
case "error":
|
||||
@@ -273,6 +277,10 @@ class OpenAIRealtimeSTTSession implements RealtimeSTTSession {
|
||||
this.onTranscriptCallback = callback;
|
||||
}
|
||||
|
||||
onSpeechStart(callback: () => void): void {
|
||||
this.onSpeechStartCallback = callback;
|
||||
}
|
||||
|
||||
async waitForTranscript(timeoutMs = 30000): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
|
||||
@@ -135,6 +135,17 @@ export class TwilioProvider implements VoiceCallProvider {
|
||||
this.callStreamMap.delete(callSid);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear TTS queue for a call (barge-in).
|
||||
* Used when user starts speaking to interrupt current TTS playback.
|
||||
*/
|
||||
clearTtsQueue(callSid: string): void {
|
||||
const streamSid = this.callStreamMap.get(callSid);
|
||||
if (streamSid && this.mediaStreamHandler) {
|
||||
this.mediaStreamHandler.clearTtsQueue(streamSid);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Make an authenticated request to the Twilio API.
|
||||
*/
|
||||
@@ -504,7 +515,7 @@ export class TwilioProvider implements VoiceCallProvider {
|
||||
/**
|
||||
* Play TTS via core TTS and Twilio Media Streams.
|
||||
* Generates audio with core TTS, converts to mu-law, and streams via WebSocket.
|
||||
* Uses a jitter buffer to smooth out timing variations.
|
||||
* Uses a queue to serialize playback and prevent overlapping audio.
|
||||
*/
|
||||
private async playTtsViaStream(
|
||||
text: string,
|
||||
@@ -514,22 +525,29 @@ export class TwilioProvider implements VoiceCallProvider {
|
||||
throw new Error("TTS provider and media stream handler required");
|
||||
}
|
||||
|
||||
// Generate audio with core TTS (returns mu-law at 8kHz)
|
||||
const muLawAudio = await this.ttsProvider.synthesizeForTelephony(text);
|
||||
|
||||
// Stream audio in 20ms chunks (160 bytes at 8kHz mu-law)
|
||||
const CHUNK_SIZE = 160;
|
||||
const CHUNK_DELAY_MS = 20;
|
||||
|
||||
for (const chunk of chunkAudio(muLawAudio, CHUNK_SIZE)) {
|
||||
this.mediaStreamHandler.sendAudio(streamSid, chunk);
|
||||
const handler = this.mediaStreamHandler;
|
||||
const ttsProvider = this.ttsProvider;
|
||||
await handler.queueTts(streamSid, async (signal) => {
|
||||
// Generate audio with core TTS (returns mu-law at 8kHz)
|
||||
const muLawAudio = await ttsProvider.synthesizeForTelephony(text);
|
||||
for (const chunk of chunkAudio(muLawAudio, CHUNK_SIZE)) {
|
||||
if (signal.aborted) break;
|
||||
handler.sendAudio(streamSid, chunk);
|
||||
|
||||
// Pace the audio to match real-time playback
|
||||
await new Promise((resolve) => setTimeout(resolve, CHUNK_DELAY_MS));
|
||||
}
|
||||
// Pace the audio to match real-time playback
|
||||
await new Promise((resolve) => setTimeout(resolve, CHUNK_DELAY_MS));
|
||||
if (signal.aborted) break;
|
||||
}
|
||||
|
||||
// Send a mark to track when audio finishes
|
||||
this.mediaStreamHandler.sendMark(streamSid, `tts-${Date.now()}`);
|
||||
if (!signal.aborted) {
|
||||
// Send a mark to track when audio finishes
|
||||
handler.sendMark(streamSid, `tts-${Date.now()}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -78,6 +78,11 @@ export class VoiceCallWebhookServer {
|
||||
`[voice-call] Transcript for ${providerCallId}: ${transcript}`,
|
||||
);
|
||||
|
||||
// Clear TTS queue on barge-in (user started speaking, interrupt current playback)
|
||||
if (this.provider.name === "twilio") {
|
||||
(this.provider as TwilioProvider).clearTtsQueue(providerCallId);
|
||||
}
|
||||
|
||||
// Look up our internal call ID from the provider call ID
|
||||
const call = this.manager.getCallByProviderCallId(providerCallId);
|
||||
if (!call) {
|
||||
@@ -109,6 +114,11 @@ export class VoiceCallWebhookServer {
|
||||
});
|
||||
}
|
||||
},
|
||||
onSpeechStart: (providerCallId) => {
|
||||
if (this.provider.name === "twilio") {
|
||||
(this.provider as TwilioProvider).clearTtsQueue(providerCallId);
|
||||
}
|
||||
},
|
||||
onPartialTranscript: (callId, partial) => {
|
||||
console.log(`[voice-call] Partial for ${callId}: ${partial}`);
|
||||
},
|
||||
|
||||
@@ -599,7 +599,7 @@ export async function runEmbeddedPiAgent(
|
||||
verboseLevel: params.verboseLevel,
|
||||
reasoningLevel: params.reasoningLevel,
|
||||
toolResultFormat: resolvedToolResultFormat,
|
||||
inlineToolResultsAllowed: !params.onPartialReply && !params.onToolResult,
|
||||
inlineToolResultsAllowed: false,
|
||||
});
|
||||
|
||||
log.debug(
|
||||
|
||||
@@ -138,6 +138,38 @@ describe("dispatchReplyFromConfig", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("does not provide onToolResult when routing cross-provider", async () => {
|
||||
mocks.tryFastAbortFromMessage.mockResolvedValue({
|
||||
handled: false,
|
||||
aborted: false,
|
||||
});
|
||||
mocks.routeReply.mockClear();
|
||||
const cfg = {} as ClawdbotConfig;
|
||||
const dispatcher = createDispatcher();
|
||||
const ctx = buildTestCtx({
|
||||
Provider: "slack",
|
||||
OriginatingChannel: "telegram",
|
||||
OriginatingTo: "telegram:999",
|
||||
});
|
||||
|
||||
const replyResolver = async (
|
||||
_ctx: MsgContext,
|
||||
opts: GetReplyOptions | undefined,
|
||||
_cfg: ClawdbotConfig,
|
||||
) => {
|
||||
expect(opts?.onToolResult).toBeUndefined();
|
||||
return { text: "hi" } satisfies ReplyPayload;
|
||||
};
|
||||
|
||||
await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver });
|
||||
|
||||
expect(mocks.routeReply).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
payload: expect.objectContaining({ text: "hi" }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("fast-aborts without calling the reply resolver", async () => {
|
||||
mocks.tryFastAbortFromMessage.mockResolvedValue({
|
||||
handled: true,
|
||||
|
||||
@@ -206,6 +206,7 @@ export async function dispatchReplyFromConfig(params: {
|
||||
const sendPayloadAsync = async (
|
||||
payload: ReplyPayload,
|
||||
abortSignal?: AbortSignal,
|
||||
mirror?: boolean,
|
||||
): Promise<void> => {
|
||||
// TypeScript doesn't narrow these from the shouldRouteToOriginating check,
|
||||
// but they're guaranteed non-null when this function is called.
|
||||
@@ -220,6 +221,7 @@ export async function dispatchReplyFromConfig(params: {
|
||||
threadId: ctx.MessageThreadId,
|
||||
cfg,
|
||||
abortSignal,
|
||||
mirror,
|
||||
});
|
||||
if (!result.ok) {
|
||||
logVerbose(`dispatch-from-config: route-reply failed: ${result.error ?? "unknown error"}`);
|
||||
@@ -268,24 +270,6 @@ export async function dispatchReplyFromConfig(params: {
|
||||
ctx,
|
||||
{
|
||||
...params.replyOptions,
|
||||
onToolResult: (payload: ReplyPayload) => {
|
||||
const run = async () => {
|
||||
const ttsPayload = await maybeApplyTtsToPayload({
|
||||
payload,
|
||||
cfg,
|
||||
channel: ttsChannel,
|
||||
kind: "tool",
|
||||
inboundAudio,
|
||||
ttsAuto: sessionTtsAuto,
|
||||
});
|
||||
if (shouldRouteToOriginating) {
|
||||
await sendPayloadAsync(ttsPayload);
|
||||
} else {
|
||||
dispatcher.sendToolResult(ttsPayload);
|
||||
}
|
||||
};
|
||||
return run();
|
||||
},
|
||||
onBlockReply: (payload: ReplyPayload, context) => {
|
||||
const run = async () => {
|
||||
const ttsPayload = await maybeApplyTtsToPayload({
|
||||
@@ -297,7 +281,7 @@ export async function dispatchReplyFromConfig(params: {
|
||||
ttsAuto: sessionTtsAuto,
|
||||
});
|
||||
if (shouldRouteToOriginating) {
|
||||
await sendPayloadAsync(ttsPayload, context?.abortSignal);
|
||||
await sendPayloadAsync(ttsPayload, context?.abortSignal, false);
|
||||
} else {
|
||||
dispatcher.sendBlockReply(ttsPayload);
|
||||
}
|
||||
|
||||
@@ -379,6 +379,23 @@ describe("routeReply", () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("skips mirror data when mirror is false", async () => {
|
||||
mocks.deliverOutboundPayloads.mockResolvedValue([]);
|
||||
await routeReply({
|
||||
payload: { text: "hi" },
|
||||
channel: "slack",
|
||||
to: "channel:C123",
|
||||
sessionKey: "agent:main:main",
|
||||
mirror: false,
|
||||
cfg: {} as never,
|
||||
});
|
||||
expect(mocks.deliverOutboundPayloads).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
mirror: undefined,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
const emptyRegistry = createRegistry([]);
|
||||
|
||||
@@ -33,6 +33,8 @@ export type RouteReplyParams = {
|
||||
cfg: ClawdbotConfig;
|
||||
/** Optional abort signal for cooperative cancellation. */
|
||||
abortSignal?: AbortSignal;
|
||||
/** Mirror reply into session transcript (default: true when sessionKey is set). */
|
||||
mirror?: boolean;
|
||||
};
|
||||
|
||||
export type RouteReplyResult = {
|
||||
@@ -118,14 +120,15 @@ export async function routeReply(params: RouteReplyParams): Promise<RouteReplyRe
|
||||
replyToId: resolvedReplyToId ?? null,
|
||||
threadId: resolvedThreadId,
|
||||
abortSignal,
|
||||
mirror: params.sessionKey
|
||||
? {
|
||||
sessionKey: params.sessionKey,
|
||||
agentId: resolveSessionAgentId({ sessionKey: params.sessionKey, config: cfg }),
|
||||
text,
|
||||
mediaUrls,
|
||||
}
|
||||
: undefined,
|
||||
mirror:
|
||||
params.mirror !== false && params.sessionKey
|
||||
? {
|
||||
sessionKey: params.sessionKey,
|
||||
agentId: resolveSessionAgentId({ sessionKey: params.sessionKey, config: cfg }),
|
||||
text,
|
||||
mediaUrls,
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
|
||||
const last = results.at(-1);
|
||||
|
||||
@@ -34,15 +34,13 @@ vi.mock("../auto-reply/dispatch.js", async (importOriginal) => {
|
||||
beforeEach(() => {
|
||||
dispatchMock.mockReset().mockImplementation(async (params) => {
|
||||
if ("dispatcher" in params && params.dispatcher) {
|
||||
params.dispatcher.sendToolResult({ text: "tool update" });
|
||||
params.dispatcher.sendFinalReply({ text: "final reply" });
|
||||
return { queuedFinal: true, counts: { tool: 1, block: 0, final: 1 } };
|
||||
return { queuedFinal: true, counts: { tool: 0, block: 0, final: 1 } };
|
||||
}
|
||||
if ("dispatcherOptions" in params && params.dispatcherOptions) {
|
||||
const { dispatcher, markDispatchIdle } = createReplyDispatcherWithTyping(
|
||||
params.dispatcherOptions,
|
||||
);
|
||||
dispatcher.sendToolResult({ text: "tool update" });
|
||||
dispatcher.sendFinalReply({ text: "final reply" });
|
||||
await dispatcher.waitForIdle();
|
||||
markDispatchIdle();
|
||||
@@ -53,7 +51,7 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
describe("discord native commands", () => {
|
||||
it("streams tool results for native slash commands", { timeout: 60_000 }, async () => {
|
||||
it("skips tool results for native slash commands", { timeout: 60_000 }, async () => {
|
||||
const { ChannelType } = await import("@buape/carbon");
|
||||
const { createDiscordNativeCommand } = await import("./monitor.js");
|
||||
|
||||
@@ -97,8 +95,7 @@ describe("discord native commands", () => {
|
||||
|
||||
expect(dispatchMock).toHaveBeenCalledTimes(1);
|
||||
expect(reply).toHaveBeenCalledTimes(1);
|
||||
expect(followUp).toHaveBeenCalledTimes(1);
|
||||
expect(reply.mock.calls[0]?.[0]?.content).toContain("tool");
|
||||
expect(followUp.mock.calls[0]?.[0]?.content).toContain("final");
|
||||
expect(followUp).toHaveBeenCalledTimes(0);
|
||||
expect(reply.mock.calls[0]?.[0]?.content).toContain("final");
|
||||
});
|
||||
});
|
||||
@@ -277,15 +277,12 @@ describe("monitorIMessageProvider", () => {
|
||||
expect(ctx.SessionKey).toBe("agent:main:imessage:group:2");
|
||||
});
|
||||
|
||||
it("prefixes tool and final replies with responsePrefix", async () => {
|
||||
it("prefixes final replies with responsePrefix", async () => {
|
||||
config = {
|
||||
...config,
|
||||
messages: { responsePrefix: "PFX" },
|
||||
};
|
||||
replyMock.mockImplementation(async (_ctx, opts) => {
|
||||
await opts?.onToolResult?.({ text: "tool update" });
|
||||
return { text: "final reply" };
|
||||
});
|
||||
replyMock.mockResolvedValue({ text: "final reply" });
|
||||
const run = monitorIMessageProvider();
|
||||
await waitForSubscribe();
|
||||
|
||||
@@ -307,9 +304,8 @@ describe("monitorIMessageProvider", () => {
|
||||
closeResolve?.();
|
||||
await run;
|
||||
|
||||
expect(sendMock).toHaveBeenCalledTimes(2);
|
||||
expect(sendMock.mock.calls[0][1]).toBe("PFX tool update");
|
||||
expect(sendMock.mock.calls[1][1]).toBe("PFX final reply");
|
||||
expect(sendMock).toHaveBeenCalledTimes(1);
|
||||
expect(sendMock.mock.calls[0][1]).toBe("PFX final reply");
|
||||
});
|
||||
|
||||
it("defaults to dmPolicy=pairing behavior when allowFrom is empty", async () => {
|
||||
|
||||
@@ -210,12 +210,9 @@ describe("monitorSignalProvider tool results", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("sends tool summaries with responsePrefix", async () => {
|
||||
it("skips tool summaries with responsePrefix", async () => {
|
||||
const abortController = new AbortController();
|
||||
replyMock.mockImplementation(async (_ctx, opts) => {
|
||||
await opts?.onToolResult?.({ text: "tool update" });
|
||||
return { text: "final reply" };
|
||||
});
|
||||
replyMock.mockResolvedValue({ text: "final reply" });
|
||||
|
||||
streamMock.mockImplementation(async ({ onEvent }) => {
|
||||
const payload = {
|
||||
@@ -243,9 +240,8 @@ describe("monitorSignalProvider tool results", () => {
|
||||
|
||||
await flush();
|
||||
|
||||
expect(sendMock).toHaveBeenCalledTimes(2);
|
||||
expect(sendMock.mock.calls[0][1]).toBe("PFX tool update");
|
||||
expect(sendMock.mock.calls[1][1]).toBe("PFX final reply");
|
||||
expect(sendMock).toHaveBeenCalledTimes(1);
|
||||
expect(sendMock.mock.calls[0][1]).toBe("PFX final reply");
|
||||
});
|
||||
|
||||
it("replies with pairing code when dmPolicy is pairing and no allowFrom is set", async () => {
|
||||
|
||||
@@ -23,11 +23,8 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
describe("monitorSlackProvider tool results", () => {
|
||||
it("sends tool summaries with responsePrefix", async () => {
|
||||
replyMock.mockImplementation(async (_ctx, opts) => {
|
||||
await opts?.onToolResult?.({ text: "tool update" });
|
||||
return { text: "final reply" };
|
||||
});
|
||||
it("skips tool summaries with responsePrefix", async () => {
|
||||
replyMock.mockResolvedValue({ text: "final reply" });
|
||||
|
||||
const controller = new AbortController();
|
||||
const run = monitorSlackProvider({
|
||||
@@ -55,9 +52,8 @@ describe("monitorSlackProvider tool results", () => {
|
||||
controller.abort();
|
||||
await run;
|
||||
|
||||
expect(sendMock).toHaveBeenCalledTimes(2);
|
||||
expect(sendMock.mock.calls[0][1]).toBe("PFX tool update");
|
||||
expect(sendMock.mock.calls[1][1]).toBe("PFX final reply");
|
||||
expect(sendMock).toHaveBeenCalledTimes(1);
|
||||
expect(sendMock.mock.calls[0][1]).toBe("PFX final reply");
|
||||
});
|
||||
|
||||
it("drops events with mismatched api_app_id", async () => {
|
||||
@@ -130,10 +126,7 @@ describe("monitorSlackProvider tool results", () => {
|
||||
},
|
||||
};
|
||||
|
||||
replyMock.mockImplementation(async (_ctx, opts) => {
|
||||
await opts?.onToolResult?.({ text: "tool update" });
|
||||
return { text: "final reply" };
|
||||
});
|
||||
replyMock.mockResolvedValue({ text: "final reply" });
|
||||
|
||||
const controller = new AbortController();
|
||||
const run = monitorSlackProvider({
|
||||
@@ -161,9 +154,8 @@ describe("monitorSlackProvider tool results", () => {
|
||||
controller.abort();
|
||||
await run;
|
||||
|
||||
expect(sendMock).toHaveBeenCalledTimes(2);
|
||||
expect(sendMock.mock.calls[0][1]).toBe("tool update");
|
||||
expect(sendMock.mock.calls[1][1]).toBe("final reply");
|
||||
expect(sendMock).toHaveBeenCalledTimes(1);
|
||||
expect(sendMock.mock.calls[0][1]).toBe("final reply");
|
||||
});
|
||||
|
||||
it("preserves RawBody without injecting processed room history", async () => {
|
||||
|
||||
+3
-4
@@ -300,7 +300,7 @@ describe("createTelegramBot", () => {
|
||||
expect.objectContaining({ message_thread_id: 99 }),
|
||||
);
|
||||
});
|
||||
it("streams tool summaries for native slash commands", async () => {
|
||||
it("skips tool summaries for native slash commands", async () => {
|
||||
onSpy.mockReset();
|
||||
sendMessageSpy.mockReset();
|
||||
commandSpy.mockReset();
|
||||
@@ -338,9 +338,8 @@ describe("createTelegramBot", () => {
|
||||
match: "on",
|
||||
});
|
||||
|
||||
expect(sendMessageSpy).toHaveBeenCalledTimes(2);
|
||||
expect(sendMessageSpy.mock.calls[0]?.[1]).toContain("tool update");
|
||||
expect(sendMessageSpy.mock.calls[1]?.[1]).toContain("final reply");
|
||||
expect(sendMessageSpy).toHaveBeenCalledTimes(1);
|
||||
expect(sendMessageSpy.mock.calls[0]?.[1]).toContain("final reply");
|
||||
});
|
||||
it("dedupes duplicate message updates by update_id", async () => {
|
||||
onSpy.mockReset();
|
||||
|
||||
+4
-8
@@ -217,15 +217,12 @@ describe("createTelegramBot", () => {
|
||||
expect(call[2]?.reply_to_message_id).toBeUndefined();
|
||||
}
|
||||
});
|
||||
it("prefixes tool and final replies with responsePrefix", async () => {
|
||||
it("prefixes final replies with responsePrefix", async () => {
|
||||
onSpy.mockReset();
|
||||
sendMessageSpy.mockReset();
|
||||
const replySpy = replyModule.__replySpy as unknown as ReturnType<typeof vi.fn>;
|
||||
replySpy.mockReset();
|
||||
replySpy.mockImplementation(async (_ctx, opts) => {
|
||||
await opts?.onToolResult?.({ text: "tool result" });
|
||||
return { text: "final reply" };
|
||||
});
|
||||
replySpy.mockResolvedValue({ text: "final reply" });
|
||||
loadConfig.mockReturnValue({
|
||||
channels: {
|
||||
telegram: { dmPolicy: "open", allowFrom: ["*"] },
|
||||
@@ -245,9 +242,8 @@ describe("createTelegramBot", () => {
|
||||
getFile: async () => ({ download: async () => new Uint8Array() }),
|
||||
});
|
||||
|
||||
expect(sendMessageSpy).toHaveBeenCalledTimes(2);
|
||||
expect(sendMessageSpy.mock.calls[0][1]).toBe("PFX tool result");
|
||||
expect(sendMessageSpy.mock.calls[1][1]).toBe("PFX final reply");
|
||||
expect(sendMessageSpy).toHaveBeenCalledTimes(1);
|
||||
expect(sendMessageSpy.mock.calls[0][1]).toBe("PFX final reply");
|
||||
});
|
||||
it("honors replyToMode=all for threaded replies", async () => {
|
||||
onSpy.mockReset();
|
||||
|
||||
@@ -865,15 +865,12 @@ describe("createTelegramBot", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("prefixes tool and final replies with responsePrefix", async () => {
|
||||
it("prefixes final replies with responsePrefix", async () => {
|
||||
onSpy.mockReset();
|
||||
sendMessageSpy.mockReset();
|
||||
const replySpy = replyModule.__replySpy as unknown as ReturnType<typeof vi.fn>;
|
||||
replySpy.mockReset();
|
||||
replySpy.mockImplementation(async (_ctx, opts) => {
|
||||
await opts?.onToolResult?.({ text: "tool result" });
|
||||
return { text: "final reply" };
|
||||
});
|
||||
replySpy.mockResolvedValue({ text: "final reply" });
|
||||
loadConfig.mockReturnValue({
|
||||
channels: {
|
||||
telegram: { dmPolicy: "open", allowFrom: ["*"] },
|
||||
@@ -893,9 +890,8 @@ describe("createTelegramBot", () => {
|
||||
getFile: async () => ({ download: async () => new Uint8Array() }),
|
||||
});
|
||||
|
||||
expect(sendMessageSpy).toHaveBeenCalledTimes(2);
|
||||
expect(sendMessageSpy.mock.calls[0][1]).toBe("PFX tool result");
|
||||
expect(sendMessageSpy.mock.calls[1][1]).toBe("PFX final reply");
|
||||
expect(sendMessageSpy).toHaveBeenCalledTimes(1);
|
||||
expect(sendMessageSpy.mock.calls[0][1]).toBe("PFX final reply");
|
||||
});
|
||||
|
||||
it("honors replyToMode=all for threaded replies", async () => {
|
||||
@@ -2288,7 +2284,7 @@ describe("createTelegramBot", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("streams tool summaries for native slash commands", async () => {
|
||||
it("skips tool summaries for native slash commands", async () => {
|
||||
onSpy.mockReset();
|
||||
sendMessageSpy.mockReset();
|
||||
commandSpy.mockReset();
|
||||
@@ -2326,9 +2322,8 @@ describe("createTelegramBot", () => {
|
||||
match: "on",
|
||||
});
|
||||
|
||||
expect(sendMessageSpy).toHaveBeenCalledTimes(2);
|
||||
expect(sendMessageSpy.mock.calls[0]?.[1]).toContain("tool update");
|
||||
expect(sendMessageSpy.mock.calls[1]?.[1]).toContain("final reply");
|
||||
expect(sendMessageSpy).toHaveBeenCalledTimes(1);
|
||||
expect(sendMessageSpy.mock.calls[0]?.[1]).toContain("final reply");
|
||||
});
|
||||
|
||||
it("dedupes duplicate message updates by update_id", async () => {
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { botApi, botCtorSpy } = vi.hoisted(() => ({
|
||||
botApi: {
|
||||
sendMessage: vi.fn(),
|
||||
setMessageReaction: vi.fn(),
|
||||
deleteMessage: vi.fn(),
|
||||
},
|
||||
botCtorSpy: vi.fn(),
|
||||
}));
|
||||
|
||||
const { loadConfig } = vi.hoisted(() => ({
|
||||
loadConfig: vi.fn(() => ({})),
|
||||
}));
|
||||
|
||||
const { makeProxyFetch } = vi.hoisted(() => ({
|
||||
makeProxyFetch: vi.fn(),
|
||||
}));
|
||||
|
||||
const { resolveTelegramFetch } = vi.hoisted(() => ({
|
||||
resolveTelegramFetch: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../config/config.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../config/config.js")>();
|
||||
return {
|
||||
...actual,
|
||||
loadConfig,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("./proxy.js", () => ({
|
||||
makeProxyFetch,
|
||||
}));
|
||||
|
||||
vi.mock("./fetch.js", () => ({
|
||||
resolveTelegramFetch,
|
||||
}));
|
||||
|
||||
vi.mock("grammy", () => ({
|
||||
Bot: class {
|
||||
api = botApi;
|
||||
constructor(
|
||||
public token: string,
|
||||
public options?: { client?: { fetch?: typeof fetch; timeoutSeconds?: number } },
|
||||
) {
|
||||
botCtorSpy(token, options);
|
||||
}
|
||||
},
|
||||
InputFile: class {},
|
||||
}));
|
||||
|
||||
import { deleteMessageTelegram, reactMessageTelegram, sendMessageTelegram } from "./send.js";
|
||||
|
||||
describe("telegram proxy client", () => {
|
||||
const proxyUrl = "http://proxy.test:8080";
|
||||
|
||||
beforeEach(() => {
|
||||
botApi.sendMessage.mockResolvedValue({ message_id: 1, chat: { id: "123" } });
|
||||
botApi.setMessageReaction.mockResolvedValue(undefined);
|
||||
botApi.deleteMessage.mockResolvedValue(true);
|
||||
botCtorSpy.mockReset();
|
||||
loadConfig.mockReturnValue({
|
||||
channels: { telegram: { accounts: { foo: { proxy: proxyUrl } } } },
|
||||
});
|
||||
makeProxyFetch.mockReset();
|
||||
resolveTelegramFetch.mockReset();
|
||||
});
|
||||
|
||||
it("uses proxy fetch for sendMessage", async () => {
|
||||
const proxyFetch = vi.fn();
|
||||
const fetchImpl = vi.fn();
|
||||
makeProxyFetch.mockReturnValue(proxyFetch as unknown as typeof fetch);
|
||||
resolveTelegramFetch.mockReturnValue(fetchImpl as unknown as typeof fetch);
|
||||
|
||||
await sendMessageTelegram("123", "hi", { token: "tok", accountId: "foo" });
|
||||
|
||||
expect(makeProxyFetch).toHaveBeenCalledWith(proxyUrl);
|
||||
expect(resolveTelegramFetch).toHaveBeenCalledWith(proxyFetch);
|
||||
expect(botCtorSpy).toHaveBeenCalledWith(
|
||||
"tok",
|
||||
expect.objectContaining({
|
||||
client: expect.objectContaining({ fetch: fetchImpl }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("uses proxy fetch for reactions", async () => {
|
||||
const proxyFetch = vi.fn();
|
||||
const fetchImpl = vi.fn();
|
||||
makeProxyFetch.mockReturnValue(proxyFetch as unknown as typeof fetch);
|
||||
resolveTelegramFetch.mockReturnValue(fetchImpl as unknown as typeof fetch);
|
||||
|
||||
await reactMessageTelegram("123", "456", "✅", { token: "tok", accountId: "foo" });
|
||||
|
||||
expect(makeProxyFetch).toHaveBeenCalledWith(proxyUrl);
|
||||
expect(resolveTelegramFetch).toHaveBeenCalledWith(proxyFetch);
|
||||
expect(botCtorSpy).toHaveBeenCalledWith(
|
||||
"tok",
|
||||
expect.objectContaining({
|
||||
client: expect.objectContaining({ fetch: fetchImpl }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("uses proxy fetch for deleteMessage", async () => {
|
||||
const proxyFetch = vi.fn();
|
||||
const fetchImpl = vi.fn();
|
||||
makeProxyFetch.mockReturnValue(proxyFetch as unknown as typeof fetch);
|
||||
resolveTelegramFetch.mockReturnValue(fetchImpl as unknown as typeof fetch);
|
||||
|
||||
await deleteMessageTelegram("123", "456", { token: "tok", accountId: "foo" });
|
||||
|
||||
expect(makeProxyFetch).toHaveBeenCalledWith(proxyUrl);
|
||||
expect(resolveTelegramFetch).toHaveBeenCalledWith(proxyFetch);
|
||||
expect(botCtorSpy).toHaveBeenCalledWith(
|
||||
"tok",
|
||||
expect.objectContaining({
|
||||
client: expect.objectContaining({ fetch: fetchImpl }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
+24
-22
@@ -17,8 +17,9 @@ import { createSubsystemLogger } from "../logging/subsystem.js";
|
||||
import { mediaKindFromMime } from "../media/constants.js";
|
||||
import { isGifMedia } from "../media/mime.js";
|
||||
import { loadWebMedia } from "../web/media.js";
|
||||
import { resolveTelegramAccount } from "./accounts.js";
|
||||
import { type ResolvedTelegramAccount, resolveTelegramAccount } from "./accounts.js";
|
||||
import { resolveTelegramFetch } from "./fetch.js";
|
||||
import { makeProxyFetch } from "./proxy.js";
|
||||
import { renderTelegramHtmlText } from "./format.js";
|
||||
import { resolveMarkdownTableMode } from "../config/markdown-tables.js";
|
||||
import { splitTelegramCaption } from "./caption.js";
|
||||
@@ -76,6 +77,25 @@ function createTelegramHttpLogger(cfg: ReturnType<typeof loadConfig>) {
|
||||
};
|
||||
}
|
||||
|
||||
function resolveTelegramClientOptions(
|
||||
account: ResolvedTelegramAccount,
|
||||
): ApiClientOptions | undefined {
|
||||
const proxyUrl = account.config.proxy?.trim();
|
||||
const proxyFetch = proxyUrl ? makeProxyFetch(proxyUrl) : undefined;
|
||||
const fetchImpl = resolveTelegramFetch(proxyFetch);
|
||||
const timeoutSeconds =
|
||||
typeof account.config.timeoutSeconds === "number" &&
|
||||
Number.isFinite(account.config.timeoutSeconds)
|
||||
? Math.max(1, Math.floor(account.config.timeoutSeconds))
|
||||
: undefined;
|
||||
return fetchImpl || timeoutSeconds
|
||||
? {
|
||||
...(fetchImpl ? { fetch: fetchImpl as unknown as ApiClientOptions["fetch"] } : {}),
|
||||
...(timeoutSeconds ? { timeoutSeconds } : {}),
|
||||
}
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function resolveToken(explicit: string | undefined, params: { accountId: string; token: string }) {
|
||||
if (explicit?.trim()) return explicit.trim();
|
||||
if (!params.token) {
|
||||
@@ -162,19 +182,7 @@ export async function sendMessageTelegram(
|
||||
const chatId = normalizeChatId(target.chatId);
|
||||
// Use provided api or create a new Bot instance. The nullish coalescing
|
||||
// operator ensures api is always defined (Bot.api is always non-null).
|
||||
const fetchImpl = resolveTelegramFetch();
|
||||
const timeoutSeconds =
|
||||
typeof account.config.timeoutSeconds === "number" &&
|
||||
Number.isFinite(account.config.timeoutSeconds)
|
||||
? Math.max(1, Math.floor(account.config.timeoutSeconds))
|
||||
: undefined;
|
||||
const client: ApiClientOptions | undefined =
|
||||
fetchImpl || timeoutSeconds
|
||||
? {
|
||||
...(fetchImpl ? { fetch: fetchImpl as unknown as ApiClientOptions["fetch"] } : {}),
|
||||
...(timeoutSeconds ? { timeoutSeconds } : {}),
|
||||
}
|
||||
: undefined;
|
||||
const client = resolveTelegramClientOptions(account);
|
||||
const api = opts.api ?? new Bot(token, client ? { client } : undefined).api;
|
||||
const mediaUrl = opts.mediaUrl?.trim();
|
||||
const replyMarkup = buildInlineKeyboard(opts.buttons);
|
||||
@@ -416,10 +424,7 @@ export async function reactMessageTelegram(
|
||||
const token = resolveToken(opts.token, account);
|
||||
const chatId = normalizeChatId(String(chatIdInput));
|
||||
const messageId = normalizeMessageId(messageIdInput);
|
||||
const fetchImpl = resolveTelegramFetch();
|
||||
const client: ApiClientOptions | undefined = fetchImpl
|
||||
? { fetch: fetchImpl as unknown as ApiClientOptions["fetch"] }
|
||||
: undefined;
|
||||
const client = resolveTelegramClientOptions(account);
|
||||
const api = opts.api ?? new Bot(token, client ? { client } : undefined).api;
|
||||
const request = createTelegramRetryRunner({
|
||||
retry: opts.retry,
|
||||
@@ -468,10 +473,7 @@ export async function deleteMessageTelegram(
|
||||
const token = resolveToken(opts.token, account);
|
||||
const chatId = normalizeChatId(String(chatIdInput));
|
||||
const messageId = normalizeMessageId(messageIdInput);
|
||||
const fetchImpl = resolveTelegramFetch();
|
||||
const client: ApiClientOptions | undefined = fetchImpl
|
||||
? { fetch: fetchImpl as unknown as ApiClientOptions["fetch"] }
|
||||
: undefined;
|
||||
const client = resolveTelegramClientOptions(account);
|
||||
const api = opts.api ?? new Bot(token, client ? { client } : undefined).api;
|
||||
const request = createTelegramRetryRunner({
|
||||
retry: opts.retry,
|
||||
|
||||
+3
-11
@@ -105,7 +105,7 @@ describe("web auto-reply", () => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("sends tool summaries immediately with responsePrefix", async () => {
|
||||
it("skips tool summaries and sends final reply with responsePrefix", async () => {
|
||||
setLoadConfigMock(() => ({
|
||||
channels: { whatsapp: { allowFrom: ["*"] } },
|
||||
messages: {
|
||||
@@ -125,15 +125,7 @@ describe("web auto-reply", () => {
|
||||
return { close: vi.fn() };
|
||||
};
|
||||
|
||||
const resolver = vi
|
||||
.fn()
|
||||
.mockImplementation(
|
||||
async (_ctx, opts?: { onToolResult?: (r: { text: string }) => Promise<void> }) => {
|
||||
await opts?.onToolResult?.({ text: "🧩 tool1" });
|
||||
await opts?.onToolResult?.({ text: "🧩 tool2" });
|
||||
return { text: "final" };
|
||||
},
|
||||
);
|
||||
const resolver = vi.fn().mockResolvedValue({ text: "final" });
|
||||
|
||||
await monitorWebChannel(false, listenerFactory, false, resolver);
|
||||
expect(capturedOnMessage).toBeDefined();
|
||||
@@ -149,7 +141,7 @@ describe("web auto-reply", () => {
|
||||
});
|
||||
|
||||
const replies = reply.mock.calls.map((call) => call[0]);
|
||||
expect(replies).toEqual(["🦞 🧩 tool1", "🦞 🧩 tool2", "🦞 final"]);
|
||||
expect(replies).toEqual(["🦞 final"]);
|
||||
resetLoadConfigMock();
|
||||
});
|
||||
it("uses identity.name for messagePrefix when set", async () => {
|
||||
|
||||
@@ -242,8 +242,10 @@
|
||||
.config-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
background: var(--panel);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* Actions Bar */
|
||||
|
||||
Reference in new issue
Block a user