openclaw/src/telegram/bot-message-context.ts

745 lines
26 KiB
TypeScript
Raw Normal View History

import type { Bot } from "grammy";
import type { MsgContext } from "../auto-reply/templating.js";
import type { OpenClawConfig } from "../config/config.js";
import type { DmPolicy, TelegramGroupConfig, TelegramTopicConfig } from "../config/types.js";
import type { StickerMetadata, TelegramContext } from "./bot/types.js";
2026-01-14 09:11:32 +00:00
import { resolveAckReaction } from "../agents/identity.js";
import {
findModelInCatalog,
loadModelCatalog,
modelSupportsVision,
} from "../agents/model-catalog.js";
import { resolveDefaultModelForAgent } from "../agents/model-selection.js";
2026-01-14 09:11:32 +00:00
import { hasControlCommand } from "../auto-reply/command-detection.js";
import { normalizeCommandBody } from "../auto-reply/commands-registry.js";
import { formatInboundEnvelope, resolveEnvelopeFormatOptions } from "../auto-reply/envelope.js";
import {
buildPendingHistoryContextFromMap,
recordPendingHistoryEntryIfEnabled,
type HistoryEntry,
} from "../auto-reply/reply/history.js";
2026-01-17 05:04:29 +00:00
import { finalizeInboundContext } from "../auto-reply/reply/inbound-context.js";
import { buildMentionRegexes, matchesMentionWithExplicit } from "../auto-reply/reply/mentions.js";
import { shouldAckReaction as shouldAckReactionGate } from "../channels/ack-reactions.js";
import { resolveControlCommandGate } from "../channels/command-gating.js";
2026-01-14 09:11:32 +00:00
import { formatLocationText, toLocationContext } from "../channels/location.js";
import { logInboundDrop } from "../channels/logging.js";
import { resolveMentionGatingWithBypass } from "../channels/mention-gating.js";
import { recordInboundSession } from "../channels/session.js";
import { loadConfig } from "../config/config.js";
import { readSessionUpdatedAt, resolveStorePath } from "../config/sessions.js";
2026-01-14 09:11:32 +00:00
import { logVerbose, shouldLogVerbose } from "../globals.js";
import { recordChannelActivity } from "../infra/channel-activity.js";
import { buildPairingReply } from "../pairing/pairing-messages.js";
import { upsertChannelPairingRequest } from "../pairing/pairing-store.js";
2026-01-14 09:11:32 +00:00
import { resolveAgentRoute } from "../routing/resolve-route.js";
import { resolveThreadSessionKeys } from "../routing/session-key.js";
2026-01-26 20:25:06 -06:00
import { withTelegramApiErrorLogging } from "./api-logging.js";
import {
firstDefined,
isSenderAllowed,
normalizeAllowFromWithStore,
resolveSenderAllowMatch,
} from "./bot-access.js";
2026-01-14 09:11:32 +00:00
import {
buildGroupLabel,
buildSenderLabel,
buildSenderName,
buildTelegramGroupFrom,
buildTelegramGroupPeerId,
buildTelegramParentPeer,
buildTypingThreadParams,
expandTextLinks,
normalizeForwardedContext,
2026-01-14 09:11:32 +00:00
describeReplyTarget,
extractTelegramLocation,
hasBotMention,
2026-02-02 08:53:42 +05:30
resolveTelegramThreadSpec,
2026-01-14 09:11:32 +00:00
} from "./bot/helpers.js";
export type TelegramMediaRef = {
path: string;
contentType?: string;
stickerMetadata?: StickerMetadata;
};
type TelegramMessageContextOptions = {
forceWasMentioned?: boolean;
messageIdOverride?: string;
};
type TelegramLogger = {
info: (obj: Record<string, unknown>, msg: string) => void;
};
type ResolveTelegramGroupConfig = (
chatId: string | number,
messageThreadId?: number,
) => { groupConfig?: TelegramGroupConfig; topicConfig?: TelegramTopicConfig };
type ResolveGroupActivation = (params: {
chatId: string | number;
agentId?: string;
messageThreadId?: number;
sessionKey?: string;
}) => boolean | undefined;
type ResolveGroupRequireMention = (chatId: string | number) => boolean;
export type BuildTelegramMessageContextParams = {
primaryCtx: TelegramContext;
allMedia: TelegramMediaRef[];
storeAllowFrom: string[];
options?: TelegramMessageContextOptions;
bot: Bot;
2026-01-30 03:15:10 +01:00
cfg: OpenClawConfig;
account: { accountId: string };
historyLimit: number;
groupHistories: Map<string, HistoryEntry[]>;
dmPolicy: DmPolicy;
allowFrom?: Array<string | number>;
groupAllowFrom?: Array<string | number>;
ackReactionScope: "off" | "group-mentions" | "group-all" | "direct" | "all";
logger: TelegramLogger;
resolveGroupActivation: ResolveGroupActivation;
resolveGroupRequireMention: ResolveGroupRequireMention;
resolveTelegramGroupConfig: ResolveTelegramGroupConfig;
};
2026-01-14 09:11:32 +00:00
async function resolveStickerVisionSupport(params: {
2026-01-30 03:15:10 +01:00
cfg: OpenClawConfig;
agentId?: string;
}): Promise<boolean> {
try {
const catalog = await loadModelCatalog({ config: params.cfg });
const defaultModel = resolveDefaultModelForAgent({
cfg: params.cfg,
agentId: params.agentId,
});
const entry = findModelInCatalog(catalog, defaultModel.provider, defaultModel.model);
if (!entry) {
return false;
}
return modelSupportsVision(entry);
} catch {
return false;
}
}
2026-01-14 09:11:32 +00:00
export const buildTelegramMessageContext = async ({
primaryCtx,
allMedia,
storeAllowFrom,
options,
bot,
cfg,
account,
historyLimit,
groupHistories,
dmPolicy,
allowFrom,
groupAllowFrom,
ackReactionScope,
logger,
resolveGroupActivation,
resolveGroupRequireMention,
resolveTelegramGroupConfig,
}: BuildTelegramMessageContextParams) => {
2026-01-14 09:11:32 +00:00
const msg = primaryCtx.message;
recordChannelActivity({
channel: "telegram",
accountId: account.accountId,
direction: "inbound",
});
const chatId = msg.chat.id;
const isGroup = msg.chat.type === "group" || msg.chat.type === "supergroup";
const messageThreadId = (msg as { message_thread_id?: number }).message_thread_id;
2026-01-14 09:11:32 +00:00
const isForum = (msg.chat as { is_forum?: boolean }).is_forum === true;
2026-02-02 08:53:42 +05:30
const threadSpec = resolveTelegramThreadSpec({
isGroup,
2026-01-14 09:11:32 +00:00
isForum,
messageThreadId,
});
2026-02-02 08:53:42 +05:30
const resolvedThreadId = threadSpec.scope === "forum" ? threadSpec.id : undefined;
const replyThreadId = threadSpec.id;
const { groupConfig, topicConfig } = resolveTelegramGroupConfig(chatId, resolvedThreadId);
const peerId = isGroup ? buildTelegramGroupPeerId(chatId, resolvedThreadId) : String(chatId);
const parentPeer = buildTelegramParentPeer({ isGroup, resolvedThreadId, chatId });
// Fresh config for bindings lookup; other routing inputs are payload-derived.
2026-01-14 09:11:32 +00:00
const route = resolveAgentRoute({
cfg: loadConfig(),
2026-01-14 09:11:32 +00:00
channel: "telegram",
accountId: account.accountId,
peer: {
refactor: unify peer kind to ChatType, rename dm to direct (#11881) * fix: use .js extension for ESM imports of RoutePeerKind The imports incorrectly used .ts extension which doesn't resolve with moduleResolution: NodeNext. Changed to .js and added 'type' import modifier. * fix tsconfig * refactor: unify peer kind to ChatType, rename dm to direct - Replace RoutePeerKind with ChatType throughout codebase - Change 'dm' literal values to 'direct' in routing/session keys - Keep backward compat: normalizeChatType accepts 'dm' -> 'direct' - Add ChatType export to plugin-sdk, deprecate RoutePeerKind - Update session key parsing to accept both 'dm' and 'direct' markers - Update all channel monitors and extensions to use ChatType BREAKING CHANGE: Session keys now use 'direct' instead of 'dm'. Existing 'dm' keys still work via backward compat layer. * fix tests * test: update session key expectations for dmdirect migration - Fix test expectations to expect :direct: in generated output - Add explicit backward compat test for normalizeChatType('dm') - Keep input test data with :dm: keys to verify backward compat * fix: accept legacy 'dm' in session key parsing for backward compat getDmHistoryLimitFromSessionKey now accepts both :dm: and :direct: to ensure old session keys continue to work correctly. * test: add explicit backward compat tests for dmdirect migration - session-key.test.ts: verify both :dm: and :direct: keys are valid - getDmHistoryLimitFromSessionKey: verify both formats work * feat: backward compat for resetByType.dm config key * test: skip unix-path Nix tests on Windows
2026-02-08 16:20:52 -08:00
kind: isGroup ? "group" : "direct",
2026-01-14 09:11:32 +00:00
id: peerId,
},
parentPeer,
2026-01-14 09:11:32 +00:00
});
const baseSessionKey = route.sessionKey;
2026-02-02 08:53:42 +05:30
// DMs: use raw messageThreadId for thread sessions (not forum topic ids)
const dmThreadId = threadSpec.scope === "dm" ? threadSpec.id : undefined;
const threadKeys =
dmThreadId != null
? resolveThreadSessionKeys({ baseSessionKey, threadId: String(dmThreadId) })
: null;
const sessionKey = threadKeys?.sessionKey ?? baseSessionKey;
2026-01-14 09:11:32 +00:00
const mentionRegexes = buildMentionRegexes(cfg, route.agentId);
const effectiveDmAllow = normalizeAllowFromWithStore({ allowFrom, storeAllowFrom });
const groupAllowOverride = firstDefined(topicConfig?.allowFrom, groupConfig?.allowFrom);
const effectiveGroupAllow = normalizeAllowFromWithStore({
allowFrom: groupAllowOverride ?? groupAllowFrom,
storeAllowFrom,
});
2026-01-14 09:11:32 +00:00
const hasGroupAllowOverride = typeof groupAllowOverride !== "undefined";
if (isGroup && groupConfig?.enabled === false) {
logVerbose(`Blocked telegram group ${chatId} (group disabled)`);
return null;
}
if (isGroup && topicConfig?.enabled === false) {
logVerbose(
`Blocked telegram topic ${chatId} (${resolvedThreadId ?? "unknown"}) (topic disabled)`,
);
return null;
}
// Compute requireMention early for preflight transcription gating
const activationOverride = resolveGroupActivation({
chatId,
messageThreadId: resolvedThreadId,
sessionKey: sessionKey,
agentId: route.agentId,
});
const baseRequireMention = resolveGroupRequireMention(chatId);
const requireMention = firstDefined(
activationOverride,
topicConfig?.requireMention,
groupConfig?.requireMention,
baseRequireMention,
);
2026-01-14 09:11:32 +00:00
const sendTyping = async () => {
2026-01-26 20:25:06 -06:00
await withTelegramApiErrorLogging({
operation: "sendChatAction",
fn: () => bot.api.sendChatAction(chatId, "typing", buildTypingThreadParams(replyThreadId)),
2026-01-26 20:25:06 -06:00
});
2026-01-14 09:11:32 +00:00
};
2026-01-16 03:15:07 +00:00
const sendRecordVoice = async () => {
try {
2026-01-26 20:25:06 -06:00
await withTelegramApiErrorLogging({
operation: "sendChatAction",
fn: () =>
bot.api.sendChatAction(chatId, "record_voice", buildTypingThreadParams(replyThreadId)),
2026-01-26 20:25:06 -06:00
});
2026-01-16 03:15:07 +00:00
} catch (err) {
logVerbose(`telegram record_voice cue failed for chat ${chatId}: ${String(err)}`);
}
};
2026-01-14 09:11:32 +00:00
// DM access control (secure defaults): "pairing" (default) / "allowlist" / "open" / "disabled"
if (!isGroup) {
if (dmPolicy === "disabled") {
return null;
}
2026-01-14 09:11:32 +00:00
if (dmPolicy !== "open") {
const senderUsername = msg.from?.username ?? "";
const senderUserId = msg.from?.id != null ? String(msg.from.id) : null;
const candidate = senderUserId ?? String(chatId);
const allowMatch = resolveSenderAllowMatch({
allow: effectiveDmAllow,
senderId: candidate,
senderUsername,
});
const allowMatchMeta = `matchKey=${allowMatch.matchKey ?? "none"} matchSource=${
allowMatch.matchSource ?? "none"
}`;
2026-01-14 09:11:32 +00:00
const allowed =
effectiveDmAllow.hasWildcard || (effectiveDmAllow.hasEntries && allowMatch.allowed);
2026-01-14 09:11:32 +00:00
if (!allowed) {
if (dmPolicy === "pairing") {
try {
const from = msg.from as
| {
first_name?: string;
last_name?: string;
username?: string;
id?: number;
}
| undefined;
const telegramUserId = from?.id ? String(from.id) : candidate;
const { code, created } = await upsertChannelPairingRequest({
channel: "telegram",
id: telegramUserId,
meta: {
username: from?.username,
firstName: from?.first_name,
lastName: from?.last_name,
},
2026-01-14 09:11:32 +00:00
});
if (created) {
logger.info(
{
chatId: String(chatId),
senderUserId: senderUserId ?? undefined,
2026-01-14 09:11:32 +00:00
username: from?.username,
firstName: from?.first_name,
lastName: from?.last_name,
matchKey: allowMatch.matchKey ?? "none",
matchSource: allowMatch.matchSource ?? "none",
2026-01-14 09:11:32 +00:00
},
"telegram pairing request",
);
2026-01-26 20:25:06 -06:00
await withTelegramApiErrorLogging({
operation: "sendMessage",
fn: () =>
bot.api.sendMessage(
chatId,
buildPairingReply({
channel: "telegram",
idLine: `Your Telegram user id: ${telegramUserId}`,
code,
}),
2026-01-26 20:25:06 -06:00
),
});
2026-01-14 09:11:32 +00:00
}
} catch (err) {
logVerbose(`telegram pairing reply failed for chat ${chatId}: ${String(err)}`);
2026-01-14 09:11:32 +00:00
}
} else {
logVerbose(
`Blocked unauthorized telegram sender ${candidate} (dmPolicy=${dmPolicy}, ${allowMatchMeta})`,
);
2026-01-14 09:11:32 +00:00
}
return null;
}
}
}
const botUsername = primaryCtx.me?.username?.toLowerCase();
const senderId = msg.from?.id ? String(msg.from.id) : "";
const senderUsername = msg.from?.username ?? "";
if (isGroup && hasGroupAllowOverride) {
const allowed = isSenderAllowed({
allow: effectiveGroupAllow,
senderId,
senderUsername,
});
if (!allowed) {
logVerbose(
`Blocked telegram group sender ${senderId || "unknown"} (group allowFrom override)`,
);
return null;
}
}
const allowForCommands = isGroup ? effectiveGroupAllow : effectiveDmAllow;
const senderAllowedForCommands = isSenderAllowed({
allow: allowForCommands,
2026-01-14 09:11:32 +00:00
senderId,
senderUsername,
});
const useAccessGroups = cfg.commands?.useAccessGroups !== false;
const hasControlCommandInMessage = hasControlCommand(msg.text ?? msg.caption ?? "", cfg, {
botUsername,
});
const commandGate = resolveControlCommandGate({
useAccessGroups,
authorizers: [{ configured: allowForCommands.hasEntries, allowed: senderAllowedForCommands }],
allowTextCommands: true,
hasControlCommand: hasControlCommandInMessage,
});
const commandAuthorized = commandGate.commandAuthorized;
const historyKey = isGroup ? buildTelegramGroupPeerId(chatId, resolvedThreadId) : undefined;
let placeholder = "";
if (msg.photo) {
placeholder = "<media:image>";
} else if (msg.video) {
placeholder = "<media:video>";
} else if (msg.video_note) {
placeholder = "<media:video>";
} else if (msg.audio || msg.voice) {
placeholder = "<media:audio>";
} else if (msg.document) {
placeholder = "<media:document>";
} else if (msg.sticker) {
placeholder = "<media:sticker>";
}
// Check if sticker has a cached description - if so, use it instead of sending the image
const cachedStickerDescription = allMedia[0]?.stickerMetadata?.cachedDescription;
const stickerSupportsVision = msg.sticker
? await resolveStickerVisionSupport({ cfg, agentId: route.agentId })
: false;
const stickerCacheHit = Boolean(cachedStickerDescription) && !stickerSupportsVision;
if (stickerCacheHit) {
// Format cached description with sticker context
const emoji = allMedia[0]?.stickerMetadata?.emoji;
const setName = allMedia[0]?.stickerMetadata?.setName;
const stickerContext = [emoji, setName ? `from "${setName}"` : null].filter(Boolean).join(" ");
placeholder = `[Sticker${stickerContext ? ` ${stickerContext}` : ""}] ${cachedStickerDescription}`;
}
const locationData = extractTelegramLocation(msg);
const locationText = locationData ? formatLocationText(locationData) : undefined;
const rawTextSource = msg.text ?? msg.caption ?? "";
const rawText = expandTextLinks(rawTextSource, msg.entities ?? msg.caption_entities).trim();
const hasUserText = Boolean(rawText || locationText);
let rawBody = [rawText, locationText].filter(Boolean).join("\n").trim();
if (!rawBody) {
rawBody = placeholder;
}
if (!rawBody && allMedia.length === 0) {
return null;
}
let bodyText = rawBody;
if (!bodyText && allMedia.length > 0) {
bodyText = `<media:image>${allMedia.length > 1 ? ` (${allMedia.length} images)` : ""}`;
}
2026-01-14 09:11:32 +00:00
const hasAnyMention = (msg.entities ?? msg.caption_entities ?? []).some(
(ent) => ent.type === "mention",
);
const explicitlyMentioned = botUsername ? hasBotMention(msg, botUsername) : false;
// Preflight audio transcription for mention detection in groups
// This allows voice notes to be checked for mentions before being dropped
let preflightTranscript: string | undefined;
const hasAudio = allMedia.some((media) => media.contentType?.startsWith("audio/"));
const needsPreflightTranscription =
isGroup && requireMention && hasAudio && !hasUserText && mentionRegexes.length > 0;
if (needsPreflightTranscription) {
try {
const { transcribeFirstAudio } = await import("../media-understanding/audio-preflight.js");
// Build a minimal context for transcription
const tempCtx: MsgContext = {
MediaPaths: allMedia.length > 0 ? allMedia.map((m) => m.path) : undefined,
MediaTypes:
allMedia.length > 0
? (allMedia.map((m) => m.contentType).filter(Boolean) as string[])
: undefined,
};
preflightTranscript = await transcribeFirstAudio({
ctx: tempCtx,
cfg,
agentDir: undefined,
});
} catch (err) {
logVerbose(`telegram: audio preflight transcription failed: ${String(err)}`);
}
}
const computedWasMentioned = matchesMentionWithExplicit({
text: msg.text ?? msg.caption ?? "",
mentionRegexes,
explicit: {
hasAnyMention,
isExplicitlyMentioned: explicitlyMentioned,
canResolveExplicit: Boolean(botUsername),
},
transcript: preflightTranscript,
});
const wasMentioned = options?.forceWasMentioned === true ? true : computedWasMentioned;
if (isGroup && commandGate.shouldBlock) {
2026-01-23 23:20:07 +00:00
logInboundDrop({
log: logVerbose,
channel: "telegram",
reason: "control command (unauthorized)",
target: senderId ?? "unknown",
});
return null;
}
// Reply-chain detection: replying to a bot message acts like an implicit mention.
const botId = primaryCtx.me?.id;
const replyFromId = msg.reply_to_message?.from?.id;
const implicitMention = botId != null && replyFromId === botId;
2026-01-14 09:11:32 +00:00
const canDetectMention = Boolean(botUsername) || mentionRegexes.length > 0;
const mentionGate = resolveMentionGatingWithBypass({
isGroup,
requireMention: Boolean(requireMention),
canDetectMention,
wasMentioned,
implicitMention: isGroup && Boolean(requireMention) && implicitMention,
hasAnyMention,
allowTextCommands: true,
hasControlCommand: hasControlCommandInMessage,
commandAuthorized,
});
const effectiveWasMentioned = mentionGate.effectiveWasMentioned;
2026-01-14 09:11:32 +00:00
if (isGroup && requireMention && canDetectMention) {
if (mentionGate.shouldSkip) {
2026-01-14 09:11:32 +00:00
logger.info({ chatId, reason: "no-mention" }, "skipping group message");
recordPendingHistoryEntryIfEnabled({
historyMap: groupHistories,
historyKey: historyKey ?? "",
limit: historyLimit,
entry: historyKey
? {
sender: buildSenderLabel(msg, senderId || chatId),
body: rawBody,
timestamp: msg.date ? msg.date * 1000 : undefined,
messageId: typeof msg.message_id === "number" ? String(msg.message_id) : undefined,
}
: null,
});
2026-01-14 09:11:32 +00:00
return null;
}
}
// ACK reactions
const ackReaction = resolveAckReaction(cfg, route.agentId);
const removeAckAfterReply = cfg.messages?.removeAckAfterReply ?? false;
const shouldAckReaction = () =>
Boolean(
ackReaction &&
shouldAckReactionGate({
scope: ackReactionScope,
isDirect: !isGroup,
isGroup,
isMentionableGroup: isGroup,
requireMention: Boolean(requireMention),
canDetectMention,
effectiveWasMentioned,
shouldBypassMention: mentionGate.shouldBypassMention,
}),
);
2026-01-14 09:11:32 +00:00
const api = bot.api as unknown as {
setMessageReaction?: (
chatId: number | string,
messageId: number,
reactions: Array<{ type: "emoji"; emoji: string }>,
) => Promise<void>;
};
const reactionApi =
typeof api.setMessageReaction === "function" ? api.setMessageReaction.bind(api) : null;
2026-01-14 09:11:32 +00:00
const ackReactionPromise =
shouldAckReaction() && msg.message_id && reactionApi
2026-01-26 20:25:06 -06:00
? withTelegramApiErrorLogging({
operation: "setMessageReaction",
fn: () => reactionApi(chatId, msg.message_id, [{ type: "emoji", emoji: ackReaction }]),
}).then(
2026-01-14 09:11:32 +00:00
() => true,
(err) => {
logVerbose(`telegram react failed for chat ${chatId}: ${String(err)}`);
2026-01-14 09:11:32 +00:00
return false;
},
)
: null;
const replyTarget = describeReplyTarget(msg);
const forwardOrigin = normalizeForwardedContext(msg);
2026-01-14 09:11:32 +00:00
const replySuffix = replyTarget
feat: Add support for Telegram quote (partial message replies) (#2900) * feat: Add support for Telegram quote (partial message replies) - Enhanced describeReplyTarget() to detect and extract quoted text from msg.quote - Updated reply formatting to distinguish between full message replies and quotes - Added isQuote flag to replyTarget object for proper identification - Quote replies show as [Quoting user] "quoted text" [/Quoting] - Regular replies unchanged: [Replying to user] full message [/Replying] Resolves need for partial message reply support in Telegram Bot API. Backward compatible with existing reply functionality. * updating references * Mac: finish Moltbot rename * Mac: finish Moltbot rename (paths) * fix(macOS): rename Clawdbot directories to Moltbot for naming consistency Directory renames: - apps/macos/Sources/Clawdbot → Moltbot - apps/macos/Sources/ClawdbotDiscovery → MoltbotDiscovery - apps/macos/Sources/ClawdbotIPC → MoltbotIPC - apps/macos/Sources/ClawdbotMacCLI → MoltbotMacCLI - apps/macos/Sources/ClawdbotProtocol → MoltbotProtocol - apps/macos/Tests/ClawdbotIPCTests → MoltbotIPCTests - apps/shared/ClawdbotKit → MoltbotKit - apps/shared/MoltbotKit/Sources/Clawdbot* → Moltbot* - apps/shared/MoltbotKit/Tests/ClawdbotKitTests → MoltbotKitTests Resource renames: - Clawdbot.icns → Moltbot.icns Code fixes: - Update Package.swift paths to reference Moltbot* directories - Fix clawdbot* → moltbot* symbol references in Swift code: - clawdbotManagedPaths → moltbotManagedPaths - clawdbotExecutable → moltbotExecutable - clawdbotCommand → moltbotCommand - clawdbotNodeCommand → moltbotNodeCommand - clawdbotOAuthDirEnv → moltbotOAuthDirEnv - clawdbotSelectSettingsTab → moltbotSelectSettingsTab * fix: update remaining ClawdbotKit path references to MoltbotKit - scripts/bundle-a2ui.sh: A2UI_APP_DIR path - package.json: format:swift and protocol:check paths - scripts/protocol-gen-swift.ts: output paths - .github/dependabot.yml: directory path and comment - .gitignore: build cache paths - .swiftformat: exclusion paths - .swiftlint.yml: exclusion path - apps/android/app/build.gradle.kts: assets.srcDir path - apps/ios/project.yml: package path - apps/ios/README.md: documentation reference - docs/concepts/typebox.md: documentation reference - apps/shared/MoltbotKit/Package.swift: fix argument order * chore: update Package.resolved after dependency resolution * fix: add MACOS_APP_SOURCES_DIR constant and update test to use new path The cron-protocol-conformance test was using LEGACY_MACOS_APP_SOURCES_DIR which points to the old Clawdbot path. Added a new MACOS_APP_SOURCES_DIR constant for the current Moltbot path and updated the test to use it. * fix: finish Moltbot macOS rename (#2844) (thanks @fal3) * Extensions: use workspace moltbot in memory-core * fix(security): recognize Venice-style claude-opus-45 as top-tier model The security audit was incorrectly flagging venice/claude-opus-45 as 'Below Claude 4.5' because the regex expected -4-5 (with dash) but Venice uses -45 (without dash between 4 and 5). Updated isClaude45OrHigher() regex to match both formats. Added test case to prevent regression. * Branding: update bot.molt bundle IDs + launchd labels * Branding: remove legacy android packages * fix: wire telegram quote support (#2900) Co-authored-by: aduk059 <aduk059@users.noreply.github.com> * fix: support Telegram quote replies (#2900) (thanks @aduk059) --------- Co-authored-by: Gustavo Madeira Santana <gumadeiras@users.noreply.github.com> Co-authored-by: Shadow <shadow@clawd.bot> Co-authored-by: Alex Fallah <alexfallah7@gmail.com> Co-authored-by: Josh Palmer <joshp123@users.noreply.github.com> Co-authored-by: jonisjongithub <jonisjongithub@users.noreply.github.com> Co-authored-by: Gustavo Madeira Santana <gumadeiras@gmail.com> Co-authored-by: aduk059 <aduk059@users.noreply.github.com>
2026-01-28 00:59:24 +04:00
? replyTarget.kind === "quote"
? `\n\n[Quoting ${replyTarget.sender}${
replyTarget.id ? ` id:${replyTarget.id}` : ""
}]\n"${replyTarget.body}"\n[/Quoting]`
: `\n\n[Replying to ${replyTarget.sender}${
replyTarget.id ? ` id:${replyTarget.id}` : ""
}]\n${replyTarget.body}\n[/Replying]`
2026-01-14 09:11:32 +00:00
: "";
const forwardPrefix = forwardOrigin
? `[Forwarded from ${forwardOrigin.from}${
forwardOrigin.date ? ` at ${new Date(forwardOrigin.date * 1000).toISOString()}` : ""
}]\n`
: "";
2026-01-17 03:40:49 +00:00
const groupLabel = isGroup ? buildGroupLabel(msg, chatId, resolvedThreadId) : undefined;
2026-01-17 05:21:02 +00:00
const senderName = buildSenderName(msg);
2026-01-17 04:04:05 +00:00
const conversationLabel = isGroup
? (groupLabel ?? `group:${chatId}`)
: buildSenderLabel(msg, senderId || chatId);
const storePath = resolveStorePath(cfg.session?.store, {
agentId: route.agentId,
});
const envelopeOptions = resolveEnvelopeFormatOptions(cfg);
const previousTimestamp = readSessionUpdatedAt({
storePath,
sessionKey: sessionKey,
});
2026-01-17 05:21:02 +00:00
const body = formatInboundEnvelope({
2026-01-17 03:40:49 +00:00
channel: "Telegram",
2026-01-17 04:04:05 +00:00
from: conversationLabel,
2026-01-17 03:40:49 +00:00
timestamp: msg.date ? msg.date * 1000 : undefined,
body: `${forwardPrefix}${bodyText}${replySuffix}`,
2026-01-17 05:21:02 +00:00
chatType: isGroup ? "group" : "direct",
sender: {
name: senderName,
username: senderUsername || undefined,
id: senderId || undefined,
},
previousTimestamp,
envelope: envelopeOptions,
2026-01-17 03:40:49 +00:00
});
2026-01-14 09:11:32 +00:00
let combinedBody = body;
if (isGroup && historyKey && historyLimit > 0) {
combinedBody = buildPendingHistoryContextFromMap({
2026-01-14 09:11:32 +00:00
historyMap: groupHistories,
historyKey,
limit: historyLimit,
currentMessage: combinedBody,
formatEntry: (entry) =>
2026-01-17 05:21:02 +00:00
formatInboundEnvelope({
2026-01-14 09:11:32 +00:00
channel: "Telegram",
from: groupLabel ?? `group:${chatId}`,
timestamp: entry.timestamp,
2026-01-17 05:21:02 +00:00
body: `${entry.body} [id:${entry.messageId ?? "unknown"} chat:${chatId}]`,
chatType: "group",
senderLabel: entry.sender,
envelope: envelopeOptions,
2026-01-14 09:11:32 +00:00
}),
});
}
const skillFilter = firstDefined(topicConfig?.skills, groupConfig?.skills);
const systemPromptParts = [
groupConfig?.systemPrompt?.trim() || null,
topicConfig?.systemPrompt?.trim() || null,
].filter((entry): entry is string => Boolean(entry));
const groupSystemPrompt =
systemPromptParts.length > 0 ? systemPromptParts.join("\n\n") : undefined;
const commandBody = normalizeCommandBody(rawBody, { botUsername });
const inboundHistory =
isGroup && historyKey && historyLimit > 0
? (groupHistories.get(historyKey) ?? []).map((entry) => ({
sender: entry.sender,
body: entry.body,
timestamp: entry.timestamp,
}))
: undefined;
2026-01-17 05:04:29 +00:00
const ctxPayload = finalizeInboundContext({
2026-01-14 09:11:32 +00:00
Body: combinedBody,
// Agent prompt should be the raw user text only; metadata/context is provided via system prompt.
BodyForAgent: bodyText,
InboundHistory: inboundHistory,
2026-01-14 09:11:32 +00:00
RawBody: rawBody,
CommandBody: commandBody,
From: isGroup ? buildTelegramGroupFrom(chatId, resolvedThreadId) : `telegram:${chatId}`,
2026-01-14 09:11:32 +00:00
To: `telegram:${chatId}`,
SessionKey: sessionKey,
2026-01-14 09:11:32 +00:00
AccountId: route.accountId,
ChatType: isGroup ? "group" : "direct",
2026-01-17 04:04:05 +00:00
ConversationLabel: conversationLabel,
2026-01-14 09:11:32 +00:00
GroupSubject: isGroup ? (msg.chat.title ?? undefined) : undefined,
GroupSystemPrompt: isGroup ? groupSystemPrompt : undefined,
2026-01-17 05:21:02 +00:00
SenderName: senderName,
2026-01-14 09:11:32 +00:00
SenderId: senderId || undefined,
SenderUsername: senderUsername || undefined,
Provider: "telegram",
Surface: "telegram",
MessageSid: options?.messageIdOverride ?? String(msg.message_id),
ReplyToId: replyTarget?.id,
ReplyToBody: replyTarget?.body,
ReplyToSender: replyTarget?.sender,
feat: Add support for Telegram quote (partial message replies) (#2900) * feat: Add support for Telegram quote (partial message replies) - Enhanced describeReplyTarget() to detect and extract quoted text from msg.quote - Updated reply formatting to distinguish between full message replies and quotes - Added isQuote flag to replyTarget object for proper identification - Quote replies show as [Quoting user] "quoted text" [/Quoting] - Regular replies unchanged: [Replying to user] full message [/Replying] Resolves need for partial message reply support in Telegram Bot API. Backward compatible with existing reply functionality. * updating references * Mac: finish Moltbot rename * Mac: finish Moltbot rename (paths) * fix(macOS): rename Clawdbot directories to Moltbot for naming consistency Directory renames: - apps/macos/Sources/Clawdbot → Moltbot - apps/macos/Sources/ClawdbotDiscovery → MoltbotDiscovery - apps/macos/Sources/ClawdbotIPC → MoltbotIPC - apps/macos/Sources/ClawdbotMacCLI → MoltbotMacCLI - apps/macos/Sources/ClawdbotProtocol → MoltbotProtocol - apps/macos/Tests/ClawdbotIPCTests → MoltbotIPCTests - apps/shared/ClawdbotKit → MoltbotKit - apps/shared/MoltbotKit/Sources/Clawdbot* → Moltbot* - apps/shared/MoltbotKit/Tests/ClawdbotKitTests → MoltbotKitTests Resource renames: - Clawdbot.icns → Moltbot.icns Code fixes: - Update Package.swift paths to reference Moltbot* directories - Fix clawdbot* → moltbot* symbol references in Swift code: - clawdbotManagedPaths → moltbotManagedPaths - clawdbotExecutable → moltbotExecutable - clawdbotCommand → moltbotCommand - clawdbotNodeCommand → moltbotNodeCommand - clawdbotOAuthDirEnv → moltbotOAuthDirEnv - clawdbotSelectSettingsTab → moltbotSelectSettingsTab * fix: update remaining ClawdbotKit path references to MoltbotKit - scripts/bundle-a2ui.sh: A2UI_APP_DIR path - package.json: format:swift and protocol:check paths - scripts/protocol-gen-swift.ts: output paths - .github/dependabot.yml: directory path and comment - .gitignore: build cache paths - .swiftformat: exclusion paths - .swiftlint.yml: exclusion path - apps/android/app/build.gradle.kts: assets.srcDir path - apps/ios/project.yml: package path - apps/ios/README.md: documentation reference - docs/concepts/typebox.md: documentation reference - apps/shared/MoltbotKit/Package.swift: fix argument order * chore: update Package.resolved after dependency resolution * fix: add MACOS_APP_SOURCES_DIR constant and update test to use new path The cron-protocol-conformance test was using LEGACY_MACOS_APP_SOURCES_DIR which points to the old Clawdbot path. Added a new MACOS_APP_SOURCES_DIR constant for the current Moltbot path and updated the test to use it. * fix: finish Moltbot macOS rename (#2844) (thanks @fal3) * Extensions: use workspace moltbot in memory-core * fix(security): recognize Venice-style claude-opus-45 as top-tier model The security audit was incorrectly flagging venice/claude-opus-45 as 'Below Claude 4.5' because the regex expected -4-5 (with dash) but Venice uses -45 (without dash between 4 and 5). Updated isClaude45OrHigher() regex to match both formats. Added test case to prevent regression. * Branding: update bot.molt bundle IDs + launchd labels * Branding: remove legacy android packages * fix: wire telegram quote support (#2900) Co-authored-by: aduk059 <aduk059@users.noreply.github.com> * fix: support Telegram quote replies (#2900) (thanks @aduk059) --------- Co-authored-by: Gustavo Madeira Santana <gumadeiras@users.noreply.github.com> Co-authored-by: Shadow <shadow@clawd.bot> Co-authored-by: Alex Fallah <alexfallah7@gmail.com> Co-authored-by: Josh Palmer <joshp123@users.noreply.github.com> Co-authored-by: jonisjongithub <jonisjongithub@users.noreply.github.com> Co-authored-by: Gustavo Madeira Santana <gumadeiras@gmail.com> Co-authored-by: aduk059 <aduk059@users.noreply.github.com>
2026-01-28 00:59:24 +04:00
ReplyToIsQuote: replyTarget?.kind === "quote" ? true : undefined,
ForwardedFrom: forwardOrigin?.from,
ForwardedFromType: forwardOrigin?.fromType,
ForwardedFromId: forwardOrigin?.fromId,
ForwardedFromUsername: forwardOrigin?.fromUsername,
ForwardedFromTitle: forwardOrigin?.fromTitle,
ForwardedFromSignature: forwardOrigin?.fromSignature,
ForwardedFromChatType: forwardOrigin?.fromChatType,
ForwardedFromMessageId: forwardOrigin?.fromMessageId,
ForwardedDate: forwardOrigin?.date ? forwardOrigin.date * 1000 : undefined,
2026-01-14 09:11:32 +00:00
Timestamp: msg.date ? msg.date * 1000 : undefined,
WasMentioned: isGroup ? effectiveWasMentioned : undefined,
// Filter out cached stickers from media - their description is already in the message body
MediaPath: stickerCacheHit ? undefined : allMedia[0]?.path,
MediaType: stickerCacheHit ? undefined : allMedia[0]?.contentType,
MediaUrl: stickerCacheHit ? undefined : allMedia[0]?.path,
MediaPaths: stickerCacheHit
? undefined
: allMedia.length > 0
? allMedia.map((m) => m.path)
: undefined,
MediaUrls: stickerCacheHit
? undefined
: allMedia.length > 0
? allMedia.map((m) => m.path)
: undefined,
MediaTypes: stickerCacheHit
? undefined
: allMedia.length > 0
2026-01-14 09:11:32 +00:00
? (allMedia.map((m) => m.contentType).filter(Boolean) as string[])
: undefined,
Sticker: allMedia[0]?.stickerMetadata,
2026-01-14 09:11:32 +00:00
...(locationData ? toLocationContext(locationData) : undefined),
CommandAuthorized: commandAuthorized,
2026-02-02 08:53:42 +05:30
// For groups: use resolved forum topic id; for DMs: use raw messageThreadId
MessageThreadId: threadSpec.id,
2026-01-14 09:11:32 +00:00
IsForum: isForum,
// Originating channel for reply routing.
OriginatingChannel: "telegram" as const,
OriginatingTo: `telegram:${chatId}`,
2026-01-17 05:04:29 +00:00
});
2026-01-14 09:11:32 +00:00
await recordInboundSession({
storePath,
sessionKey: ctxPayload.SessionKey ?? sessionKey,
ctx: ctxPayload,
updateLastRoute: !isGroup
? {
sessionKey: route.mainSessionKey,
channel: "telegram",
to: String(chatId),
accountId: route.accountId,
// Preserve DM topic threadId for replies (fixes #8891)
threadId: dmThreadId != null ? String(dmThreadId) : undefined,
}
: undefined,
onRecordError: (err) => {
logVerbose(`telegram: failed updating session meta: ${String(err)}`);
},
});
2026-01-14 09:11:32 +00:00
if (replyTarget && shouldLogVerbose()) {
const preview = replyTarget.body.replace(/\s+/g, " ").slice(0, 120);
logVerbose(
`telegram reply-context: replyToId=${replyTarget.id} replyToSender=${replyTarget.sender} replyToBody="${preview}"`,
);
}
if (forwardOrigin && shouldLogVerbose()) {
logVerbose(
`telegram forward-context: forwardedFrom="${forwardOrigin.from}" type=${forwardOrigin.fromType}`,
);
}
2026-01-14 09:11:32 +00:00
if (shouldLogVerbose()) {
const preview = body.slice(0, 200).replace(/\n/g, "\\n");
const mediaInfo = allMedia.length > 1 ? ` mediaCount=${allMedia.length}` : "";
const topicInfo = resolvedThreadId != null ? ` topic=${resolvedThreadId}` : "";
2026-01-14 09:11:32 +00:00
logVerbose(
`telegram inbound: chatId=${chatId} from=${ctxPayload.From} len=${body.length}${mediaInfo}${topicInfo} preview="${preview}"`,
);
}
return {
ctxPayload,
primaryCtx,
msg,
chatId,
isGroup,
resolvedThreadId,
2026-02-02 08:53:42 +05:30
threadSpec,
replyThreadId,
2026-01-14 09:11:32 +00:00
isForum,
historyKey,
historyLimit,
groupHistories,
route,
skillFilter,
sendTyping,
2026-01-16 03:15:07 +00:00
sendRecordVoice,
2026-01-14 09:11:32 +00:00
ackReactionPromise,
reactionApi,
removeAckAfterReply,
accountId: account.accountId,
};
};
feat: per-channel responsePrefix override (#9001) * feat: per-channel responsePrefix override Add responsePrefix field to all channel config types and Zod schemas, enabling per-channel and per-account outbound response prefix overrides. Resolution cascade (most specific wins): L1: channels.<ch>.accounts.<id>.responsePrefix L2: channels.<ch>.responsePrefix L3: (reserved for channels.defaults) L4: messages.responsePrefix (existing global) Semantics: - undefined -> inherit from parent level - empty string -> explicitly no prefix (stops cascade) - "auto" -> derive [identity.name] from routed agent Changes: - Core logic: resolveResponsePrefix() in identity.ts accepts optional channel/accountId and walks the cascade - resolveEffectiveMessagesConfig() passes channel context through - Types: responsePrefix added to WhatsApp, Telegram, Discord, Slack, Signal, iMessage, Google Chat, MS Teams, Feishu, BlueBubbles configs - Zod schemas: responsePrefix added for config validation - All channel handlers wired: telegram, discord, slack, signal, imessage, line, heartbeat runner, route-reply, native commands - 23 new tests covering backward compat, channel/account levels, full cascade, auto keyword, empty string stops, unknown fallthrough Fully backward compatible - no existing config is affected. Fixes #8857 * fix: address CI lint + review feedback - Replace Record<string, any> with proper typed helpers (no-explicit-any) - Add curly braces to single-line if returns (eslint curly) - Fix JSDoc: 'Per-channel' → 'channel/account' on shared config types - Extract getChannelConfig() helper for type-safe dynamic key access * fix: finish responsePrefix overrides (#9001) (thanks @mudrii) * fix: normalize prefix wiring and types (#9001) (thanks @mudrii) --------- Co-authored-by: Gustavo Madeira Santana <gumadeiras@gmail.com>
2026-02-05 05:16:34 +08:00
export type TelegramMessageContext = NonNullable<
Awaited<ReturnType<typeof buildTelegramMessageContext>>
>;