Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
50c59ac7ca |
No files matched your search
@@ -67,7 +67,6 @@ Status: unreleased.
|
|||||||
- Agents: release session locks on process termination and cover more signals. (#2483) Thanks @janeexai.
|
- Agents: release session locks on process termination and cover more signals. (#2483) Thanks @janeexai.
|
||||||
- Telegram: harden polling + retry behavior for transient network errors and Node 22 transport issues. (#2420) Thanks @techboss.
|
- Telegram: harden polling + retry behavior for transient network errors and Node 22 transport issues. (#2420) Thanks @techboss.
|
||||||
- Telegram: wrap reasoning italics per line to avoid raw underscores. (#2181) Thanks @YuriNachos.
|
- Telegram: wrap reasoning italics per line to avoid raw underscores. (#2181) Thanks @YuriNachos.
|
||||||
- Telegram: log fetch/API errors in delivery to avoid unhandled rejections. (#2492) Thanks @altryne.
|
|
||||||
- Voice Call: enforce Twilio webhook signature verification for ngrok URLs; disable ngrok free tier bypass by default.
|
- Voice Call: enforce Twilio webhook signature verification for ngrok URLs; disable ngrok free tier bypass by default.
|
||||||
- Security: harden Tailscale Serve auth by validating identity via local tailscaled before trusting headers.
|
- Security: harden Tailscale Serve auth by validating identity via local tailscaled before trusting headers.
|
||||||
- Build: align memory-core peer dependency with lockfile.
|
- Build: align memory-core peer dependency with lockfile.
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ export function createMSTeamsReplyDispatcher(params: {
|
|||||||
}) {
|
}) {
|
||||||
const core = getMSTeamsRuntime();
|
const core = getMSTeamsRuntime();
|
||||||
const sendTypingIndicator = async () => {
|
const sendTypingIndicator = async () => {
|
||||||
await params.context.sendActivity({ type: "typing" });
|
await params.context.sendActivities([{ type: "typing" }]);
|
||||||
};
|
};
|
||||||
const typingCallbacks = createTypingCallbacks({
|
const typingCallbacks = createTypingCallbacks({
|
||||||
start: sendTypingIndicator,
|
start: sendTypingIndicator,
|
||||||
@@ -70,38 +70,38 @@ export function createMSTeamsReplyDispatcher(params: {
|
|||||||
const tableMode = core.channel.text.resolveMarkdownTableMode({
|
const tableMode = core.channel.text.resolveMarkdownTableMode({
|
||||||
cfg: params.cfg,
|
cfg: params.cfg,
|
||||||
channel: "msteams",
|
channel: "msteams",
|
||||||
});
|
});
|
||||||
const messages = renderReplyPayloadsToMessages([payload], {
|
const messages = renderReplyPayloadsToMessages([payload], {
|
||||||
textChunkLimit: params.textLimit,
|
textChunkLimit: params.textLimit,
|
||||||
chunkText: true,
|
chunkText: true,
|
||||||
mediaMode: "split",
|
mediaMode: "split",
|
||||||
tableMode,
|
tableMode,
|
||||||
chunkMode,
|
chunkMode,
|
||||||
});
|
});
|
||||||
const mediaMaxBytes = resolveChannelMediaMaxBytes({
|
const mediaMaxBytes = resolveChannelMediaMaxBytes({
|
||||||
cfg: params.cfg,
|
cfg: params.cfg,
|
||||||
resolveChannelLimitMb: ({ cfg }) => cfg.channels?.msteams?.mediaMaxMb,
|
resolveChannelLimitMb: ({ cfg }) => cfg.channels?.msteams?.mediaMaxMb,
|
||||||
});
|
});
|
||||||
const ids = await sendMSTeamsMessages({
|
const ids = await sendMSTeamsMessages({
|
||||||
replyStyle: params.replyStyle,
|
replyStyle: params.replyStyle,
|
||||||
adapter: params.adapter,
|
adapter: params.adapter,
|
||||||
appId: params.appId,
|
appId: params.appId,
|
||||||
conversationRef: params.conversationRef,
|
conversationRef: params.conversationRef,
|
||||||
context: params.context,
|
context: params.context,
|
||||||
messages,
|
messages,
|
||||||
// Enable default retry/backoff for throttling/transient failures.
|
// Enable default retry/backoff for throttling/transient failures.
|
||||||
retry: {},
|
retry: {},
|
||||||
onRetry: (event) => {
|
onRetry: (event) => {
|
||||||
params.log.debug("retrying send", {
|
params.log.debug("retrying send", {
|
||||||
replyStyle: params.replyStyle,
|
replyStyle: params.replyStyle,
|
||||||
...event,
|
...event,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
tokenProvider: params.tokenProvider,
|
tokenProvider: params.tokenProvider,
|
||||||
sharePointSiteId: params.sharePointSiteId,
|
sharePointSiteId: params.sharePointSiteId,
|
||||||
mediaMaxBytes,
|
mediaMaxBytes,
|
||||||
});
|
});
|
||||||
if (ids.length > 0) params.onSentMessageIds?.(ids);
|
if (ids.length > 0) params.onSentMessageIds?.(ids);
|
||||||
},
|
},
|
||||||
onError: (err, info) => {
|
onError: (err, info) => {
|
||||||
const errMsg = formatUnknownError(err);
|
const errMsg = formatUnknownError(err);
|
||||||
|
|||||||
@@ -1,93 +0,0 @@
|
|||||||
import fs from "node:fs";
|
|
||||||
import path from "node:path";
|
|
||||||
import os from "node:os";
|
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
||||||
import { resolveApiKeyForProfile } from "./oauth.js";
|
|
||||||
import type { AuthProfileStore } from "./types.js";
|
|
||||||
|
|
||||||
describe("resolveApiKeyForProfile", () => {
|
|
||||||
let tmpDir: string;
|
|
||||||
let mainAgentDir: string;
|
|
||||||
let secondaryAgentDir: string;
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "oauth-test-"));
|
|
||||||
mainAgentDir = path.join(tmpDir, "agents", "main", "agent");
|
|
||||||
secondaryAgentDir = path.join(tmpDir, "agents", "kids", "agent");
|
|
||||||
await fs.promises.mkdir(mainAgentDir, { recursive: true });
|
|
||||||
await fs.promises.mkdir(secondaryAgentDir, { recursive: true });
|
|
||||||
|
|
||||||
// Set env to use our temp dir
|
|
||||||
process.env.CLAWDBOT_STATE_DIR = tmpDir;
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(async () => {
|
|
||||||
delete process.env.CLAWDBOT_STATE_DIR;
|
|
||||||
await fs.promises.rm(tmpDir, { recursive: true, force: true });
|
|
||||||
vi.restoreAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("falls back to main agent credentials when secondary agent token is expired and refresh fails", async () => {
|
|
||||||
const profileId = "anthropic:claude-cli";
|
|
||||||
const now = Date.now();
|
|
||||||
const expiredTime = now - 60 * 60 * 1000; // 1 hour ago
|
|
||||||
const freshTime = now + 60 * 60 * 1000; // 1 hour from now
|
|
||||||
|
|
||||||
// Write expired credentials for secondary agent
|
|
||||||
const secondaryStore: AuthProfileStore = {
|
|
||||||
version: 1,
|
|
||||||
profiles: {
|
|
||||||
[profileId]: {
|
|
||||||
type: "oauth",
|
|
||||||
provider: "anthropic",
|
|
||||||
access: "expired-access-token",
|
|
||||||
refresh: "expired-refresh-token",
|
|
||||||
expires: expiredTime,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
await fs.promises.writeFile(
|
|
||||||
path.join(secondaryAgentDir, "auth-profiles.json"),
|
|
||||||
JSON.stringify(secondaryStore),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Write fresh credentials for main agent
|
|
||||||
const mainStore: AuthProfileStore = {
|
|
||||||
version: 1,
|
|
||||||
profiles: {
|
|
||||||
[profileId]: {
|
|
||||||
type: "oauth",
|
|
||||||
provider: "anthropic",
|
|
||||||
access: "fresh-access-token",
|
|
||||||
refresh: "fresh-refresh-token",
|
|
||||||
expires: freshTime,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
await fs.promises.writeFile(
|
|
||||||
path.join(mainAgentDir, "auth-profiles.json"),
|
|
||||||
JSON.stringify(mainStore),
|
|
||||||
);
|
|
||||||
|
|
||||||
// The secondary agent should fall back to main agent's credentials
|
|
||||||
// when its own token refresh fails
|
|
||||||
const result = await resolveApiKeyForProfile({
|
|
||||||
store: secondaryStore,
|
|
||||||
profileId,
|
|
||||||
agentDir: secondaryAgentDir,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result).not.toBeNull();
|
|
||||||
expect(result?.apiKey).toBe("fresh-access-token");
|
|
||||||
expect(result?.provider).toBe("anthropic");
|
|
||||||
|
|
||||||
// Verify the credentials were copied to the secondary agent
|
|
||||||
const updatedSecondaryStore = JSON.parse(
|
|
||||||
await fs.promises.readFile(path.join(secondaryAgentDir, "auth-profiles.json"), "utf8"),
|
|
||||||
) as AuthProfileStore;
|
|
||||||
expect(updatedSecondaryStore.profiles[profileId]).toMatchObject({
|
|
||||||
access: "fresh-access-token",
|
|
||||||
expires: freshTime,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -4,7 +4,7 @@ import lockfile from "proper-lockfile";
|
|||||||
import type { ClawdbotConfig } from "../../config/config.js";
|
import type { ClawdbotConfig } from "../../config/config.js";
|
||||||
import { refreshChutesTokens } from "../chutes-oauth.js";
|
import { refreshChutesTokens } from "../chutes-oauth.js";
|
||||||
import { refreshQwenPortalCredentials } from "../../providers/qwen-portal-oauth.js";
|
import { refreshQwenPortalCredentials } from "../../providers/qwen-portal-oauth.js";
|
||||||
import { AUTH_STORE_LOCK_OPTIONS, log } from "./constants.js";
|
import { AUTH_STORE_LOCK_OPTIONS } from "./constants.js";
|
||||||
import { formatAuthDoctorHint } from "./doctor.js";
|
import { formatAuthDoctorHint } from "./doctor.js";
|
||||||
import { ensureAuthStoreFile, resolveAuthStorePath } from "./paths.js";
|
import { ensureAuthStoreFile, resolveAuthStorePath } from "./paths.js";
|
||||||
import { suggestOAuthProfileIdForLegacyDefault } from "./repair.js";
|
import { suggestOAuthProfileIdForLegacyDefault } from "./repair.js";
|
||||||
@@ -196,32 +196,6 @@ export async function resolveApiKeyForProfile(params: {
|
|||||||
// keep original error
|
// keep original error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: if this is a secondary agent, try using the main agent's credentials
|
|
||||||
if (params.agentDir) {
|
|
||||||
try {
|
|
||||||
const mainStore = ensureAuthProfileStore(undefined); // main agent (no agentDir)
|
|
||||||
const mainCred = mainStore.profiles[profileId];
|
|
||||||
if (mainCred?.type === "oauth" && Date.now() < mainCred.expires) {
|
|
||||||
// Main agent has fresh credentials - copy them to this agent and use them
|
|
||||||
refreshedStore.profiles[profileId] = { ...mainCred };
|
|
||||||
saveAuthProfileStore(refreshedStore, params.agentDir);
|
|
||||||
log.info("inherited fresh OAuth credentials from main agent", {
|
|
||||||
profileId,
|
|
||||||
agentDir: params.agentDir,
|
|
||||||
expires: new Date(mainCred.expires).toISOString(),
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
apiKey: buildOAuthApiKey(mainCred.provider, mainCred),
|
|
||||||
provider: mainCred.provider,
|
|
||||||
email: mainCred.email,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// keep original error if main agent fallback also fails
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
const hint = formatAuthDoctorHint({
|
const hint = formatAuthDoctorHint({
|
||||||
cfg,
|
cfg,
|
||||||
|
|||||||
@@ -80,20 +80,14 @@ async function promptTelegramAllowFrom(params: {
|
|||||||
if (!token) return null;
|
if (!token) return null;
|
||||||
const username = stripped.startsWith("@") ? stripped : `@${stripped}`;
|
const username = stripped.startsWith("@") ? stripped : `@${stripped}`;
|
||||||
const url = `https://api.telegram.org/bot${token}/getChat?chat_id=${encodeURIComponent(username)}`;
|
const url = `https://api.telegram.org/bot${token}/getChat?chat_id=${encodeURIComponent(username)}`;
|
||||||
try {
|
const res = await fetch(url);
|
||||||
const res = await fetch(url);
|
const data = (await res.json().catch(() => null)) as {
|
||||||
if (!res.ok) return null;
|
ok?: boolean;
|
||||||
const data = (await res.json().catch(() => null)) as {
|
result?: { id?: number | string };
|
||||||
ok?: boolean;
|
} | null;
|
||||||
result?: { id?: number | string };
|
const id = data?.ok ? data?.result?.id : undefined;
|
||||||
} | null;
|
if (typeof id === "number" || typeof id === "string") return String(id);
|
||||||
const id = data?.ok ? data?.result?.id : undefined;
|
return null;
|
||||||
if (typeof id === "number" || typeof id === "string") return String(id);
|
|
||||||
return null;
|
|
||||||
} catch {
|
|
||||||
// Network error during username lookup - return null to prompt user for numeric ID
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const parseInput = (value: string) =>
|
const parseInput = (value: string) =>
|
||||||
|
|||||||
@@ -25,24 +25,6 @@ import type { TelegramContext } from "./types.js";
|
|||||||
const PARSE_ERR_RE = /can't parse entities|parse entities|find end of the entity/i;
|
const PARSE_ERR_RE = /can't parse entities|parse entities|find end of the entity/i;
|
||||||
const VOICE_FORBIDDEN_RE = /VOICE_MESSAGES_FORBIDDEN/;
|
const VOICE_FORBIDDEN_RE = /VOICE_MESSAGES_FORBIDDEN/;
|
||||||
|
|
||||||
/**
|
|
||||||
* Wraps a Telegram API call with error logging. Ensures network failures are
|
|
||||||
* logged with context before propagating, preventing silent unhandled rejections.
|
|
||||||
*/
|
|
||||||
async function withMediaErrorHandler<T>(
|
|
||||||
operation: string,
|
|
||||||
runtime: RuntimeEnv,
|
|
||||||
fn: () => Promise<T>,
|
|
||||||
): Promise<T> {
|
|
||||||
try {
|
|
||||||
return await fn();
|
|
||||||
} catch (err) {
|
|
||||||
const errText = formatErrorMessage(err);
|
|
||||||
runtime.error?.(danger(`telegram ${operation} failed: ${errText}`));
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function deliverReplies(params: {
|
export async function deliverReplies(params: {
|
||||||
replies: ReplyPayload[];
|
replies: ReplyPayload[];
|
||||||
chatId: string;
|
chatId: string;
|
||||||
@@ -164,17 +146,17 @@ export async function deliverReplies(params: {
|
|||||||
mediaParams.message_thread_id = threadParams.message_thread_id;
|
mediaParams.message_thread_id = threadParams.message_thread_id;
|
||||||
}
|
}
|
||||||
if (isGif) {
|
if (isGif) {
|
||||||
await withMediaErrorHandler("sendAnimation", runtime, () =>
|
await bot.api.sendAnimation(chatId, file, {
|
||||||
bot.api.sendAnimation(chatId, file, { ...mediaParams }),
|
...mediaParams,
|
||||||
);
|
});
|
||||||
} else if (kind === "image") {
|
} else if (kind === "image") {
|
||||||
await withMediaErrorHandler("sendPhoto", runtime, () =>
|
await bot.api.sendPhoto(chatId, file, {
|
||||||
bot.api.sendPhoto(chatId, file, { ...mediaParams }),
|
...mediaParams,
|
||||||
);
|
});
|
||||||
} else if (kind === "video") {
|
} else if (kind === "video") {
|
||||||
await withMediaErrorHandler("sendVideo", runtime, () =>
|
await bot.api.sendVideo(chatId, file, {
|
||||||
bot.api.sendVideo(chatId, file, { ...mediaParams }),
|
...mediaParams,
|
||||||
);
|
});
|
||||||
} else if (kind === "audio") {
|
} else if (kind === "audio") {
|
||||||
const { useVoice } = resolveTelegramVoiceSend({
|
const { useVoice } = resolveTelegramVoiceSend({
|
||||||
wantsVoice: reply.audioAsVoice === true, // default false (backward compatible)
|
wantsVoice: reply.audioAsVoice === true, // default false (backward compatible)
|
||||||
@@ -187,9 +169,9 @@ export async function deliverReplies(params: {
|
|||||||
// Switch typing indicator to record_voice before sending.
|
// Switch typing indicator to record_voice before sending.
|
||||||
await params.onVoiceRecording?.();
|
await params.onVoiceRecording?.();
|
||||||
try {
|
try {
|
||||||
await withMediaErrorHandler("sendVoice", runtime, () =>
|
await bot.api.sendVoice(chatId, file, {
|
||||||
bot.api.sendVoice(chatId, file, { ...mediaParams }),
|
...mediaParams,
|
||||||
);
|
});
|
||||||
} catch (voiceErr) {
|
} catch (voiceErr) {
|
||||||
// Fall back to text if voice messages are forbidden in this chat.
|
// Fall back to text if voice messages are forbidden in this chat.
|
||||||
// This happens when the recipient has Telegram Premium privacy settings
|
// This happens when the recipient has Telegram Premium privacy settings
|
||||||
@@ -222,14 +204,14 @@ export async function deliverReplies(params: {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Audio file - displays with metadata (title, duration) - DEFAULT
|
// Audio file - displays with metadata (title, duration) - DEFAULT
|
||||||
await withMediaErrorHandler("sendAudio", runtime, () =>
|
await bot.api.sendAudio(chatId, file, {
|
||||||
bot.api.sendAudio(chatId, file, { ...mediaParams }),
|
...mediaParams,
|
||||||
);
|
});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
await withMediaErrorHandler("sendDocument", runtime, () =>
|
await bot.api.sendDocument(chatId, file, {
|
||||||
bot.api.sendDocument(chatId, file, { ...mediaParams }),
|
...mediaParams,
|
||||||
);
|
});
|
||||||
}
|
}
|
||||||
if (replyToId && !hasReplied) {
|
if (replyToId && !hasReplied) {
|
||||||
hasReplied = true;
|
hasReplied = true;
|
||||||
|
|||||||
@@ -127,12 +127,6 @@ export function connectGateway(host: GatewayHost) {
|
|||||||
host.lastError = null;
|
host.lastError = null;
|
||||||
host.hello = hello;
|
host.hello = hello;
|
||||||
applySnapshot(host, hello);
|
applySnapshot(host, hello);
|
||||||
// Reset orphaned chat run state from before disconnect.
|
|
||||||
// Any in-flight run's final event was lost during the disconnect window.
|
|
||||||
host.chatRunId = null;
|
|
||||||
(host as unknown as { chatStream: string | null }).chatStream = null;
|
|
||||||
(host as unknown as { chatStreamStartedAt: number | null }).chatStreamStartedAt = null;
|
|
||||||
resetToolStream(host as unknown as Parameters<typeof resetToolStream>[0]);
|
|
||||||
void loadAssistantIdentity(host as unknown as ClawdbotApp);
|
void loadAssistantIdentity(host as unknown as ClawdbotApp);
|
||||||
void loadAgents(host as unknown as ClawdbotApp);
|
void loadAgents(host as unknown as ClawdbotApp);
|
||||||
void loadNodes(host as unknown as ClawdbotApp, { quiet: true });
|
void loadNodes(host as unknown as ClawdbotApp, { quiet: true });
|
||||||
|
|||||||
Reference in new issue
Block a user