mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-02 19:00:22 +00:00
refactor: move Telegram channel implementation to extensions/ (#45635)
* refactor: move Telegram channel implementation to extensions/telegram/src/ Move all Telegram channel code (123 files + 10 bot/ files + 8 channel plugin files) from src/telegram/ and src/channels/plugins/*/telegram.ts to extensions/telegram/src/. Leave thin re-export shims at original locations so cross-cutting src/ imports continue to resolve. - Fix all relative import paths in moved files (../X/ -> ../../../src/X/) - Fix vi.mock paths in 60 test files - Fix inline typeof import() expressions - Update tsconfig.plugin-sdk.dts.json rootDir to "." for cross-directory DTS - Update write-plugin-sdk-entry-dts.ts for new rootDir structure - Move channel plugin files with correct path remapping * fix: support keyed telegram send deps * fix: sync telegram extension copies with latest main * fix: correct import paths and remove misplaced files in telegram extension * fix: sync outbound-adapter with main (add sendTelegramPayloadMessages) and fix delivery.test import path
This commit is contained in:
473
extensions/telegram/src/bot-message-context.ts
Normal file
473
extensions/telegram/src/bot-message-context.ts
Normal file
@@ -0,0 +1,473 @@
|
||||
import { ensureConfiguredAcpRouteReady } from "../../../src/acp/persistent-bindings.route.js";
|
||||
import { resolveAckReaction } from "../../../src/agents/identity.js";
|
||||
import { shouldAckReaction as shouldAckReactionGate } from "../../../src/channels/ack-reactions.js";
|
||||
import { logInboundDrop } from "../../../src/channels/logging.js";
|
||||
import {
|
||||
createStatusReactionController,
|
||||
type StatusReactionController,
|
||||
} from "../../../src/channels/status-reactions.js";
|
||||
import { loadConfig } from "../../../src/config/config.js";
|
||||
import type { TelegramDirectConfig, TelegramGroupConfig } from "../../../src/config/types.js";
|
||||
import { logVerbose } from "../../../src/globals.js";
|
||||
import { recordChannelActivity } from "../../../src/infra/channel-activity.js";
|
||||
import { buildAgentSessionKey, deriveLastRoutePolicy } from "../../../src/routing/resolve-route.js";
|
||||
import { DEFAULT_ACCOUNT_ID, resolveThreadSessionKeys } from "../../../src/routing/session-key.js";
|
||||
import { withTelegramApiErrorLogging } from "./api-logging.js";
|
||||
import { firstDefined, normalizeAllowFrom, normalizeDmAllowFromWithStore } from "./bot-access.js";
|
||||
import { resolveTelegramInboundBody } from "./bot-message-context.body.js";
|
||||
import { buildTelegramInboundContextPayload } from "./bot-message-context.session.js";
|
||||
import type { BuildTelegramMessageContextParams } from "./bot-message-context.types.js";
|
||||
import {
|
||||
buildTypingThreadParams,
|
||||
resolveTelegramDirectPeerId,
|
||||
resolveTelegramThreadSpec,
|
||||
} from "./bot/helpers.js";
|
||||
import { resolveTelegramConversationRoute } from "./conversation-route.js";
|
||||
import { enforceTelegramDmAccess } from "./dm-access.js";
|
||||
import { evaluateTelegramGroupBaseAccess } from "./group-access.js";
|
||||
import {
|
||||
buildTelegramStatusReactionVariants,
|
||||
resolveTelegramAllowedEmojiReactions,
|
||||
resolveTelegramReactionVariant,
|
||||
resolveTelegramStatusReactionEmojis,
|
||||
} from "./status-reaction-variants.js";
|
||||
|
||||
export type {
|
||||
BuildTelegramMessageContextParams,
|
||||
TelegramMediaRef,
|
||||
} from "./bot-message-context.types.js";
|
||||
|
||||
export const buildTelegramMessageContext = async ({
|
||||
primaryCtx,
|
||||
allMedia,
|
||||
replyMedia = [],
|
||||
storeAllowFrom,
|
||||
options,
|
||||
bot,
|
||||
cfg,
|
||||
account,
|
||||
historyLimit,
|
||||
groupHistories,
|
||||
dmPolicy,
|
||||
allowFrom,
|
||||
groupAllowFrom,
|
||||
ackReactionScope,
|
||||
logger,
|
||||
resolveGroupActivation,
|
||||
resolveGroupRequireMention,
|
||||
resolveTelegramGroupConfig,
|
||||
sendChatActionHandler,
|
||||
}: BuildTelegramMessageContextParams) => {
|
||||
const msg = primaryCtx.message;
|
||||
const chatId = msg.chat.id;
|
||||
const isGroup = msg.chat.type === "group" || msg.chat.type === "supergroup";
|
||||
const senderId = msg.from?.id ? String(msg.from.id) : "";
|
||||
const messageThreadId = (msg as { message_thread_id?: number }).message_thread_id;
|
||||
const isForum = (msg.chat as { is_forum?: boolean }).is_forum === true;
|
||||
const threadSpec = resolveTelegramThreadSpec({
|
||||
isGroup,
|
||||
isForum,
|
||||
messageThreadId,
|
||||
});
|
||||
const resolvedThreadId = threadSpec.scope === "forum" ? threadSpec.id : undefined;
|
||||
const replyThreadId = threadSpec.id;
|
||||
const dmThreadId = threadSpec.scope === "dm" ? threadSpec.id : undefined;
|
||||
const threadIdForConfig = resolvedThreadId ?? dmThreadId;
|
||||
const { groupConfig, topicConfig } = resolveTelegramGroupConfig(chatId, threadIdForConfig);
|
||||
// Use direct config dmPolicy override if available for DMs
|
||||
const effectiveDmPolicy =
|
||||
!isGroup && groupConfig && "dmPolicy" in groupConfig
|
||||
? (groupConfig.dmPolicy ?? dmPolicy)
|
||||
: dmPolicy;
|
||||
// Fresh config for bindings lookup; other routing inputs are payload-derived.
|
||||
const freshCfg = loadConfig();
|
||||
let { route, configuredBinding, configuredBindingSessionKey } = resolveTelegramConversationRoute({
|
||||
cfg: freshCfg,
|
||||
accountId: account.accountId,
|
||||
chatId,
|
||||
isGroup,
|
||||
resolvedThreadId,
|
||||
replyThreadId,
|
||||
senderId,
|
||||
topicAgentId: topicConfig?.agentId,
|
||||
});
|
||||
const requiresExplicitAccountBinding = (
|
||||
candidate: ReturnType<typeof resolveTelegramConversationRoute>["route"],
|
||||
): boolean => candidate.accountId !== DEFAULT_ACCOUNT_ID && candidate.matchedBy === "default";
|
||||
const isNamedAccountFallback = requiresExplicitAccountBinding(route);
|
||||
// Named-account groups still require an explicit binding; DMs get a
|
||||
// per-account fallback session key below to preserve isolation.
|
||||
if (isNamedAccountFallback && isGroup) {
|
||||
logInboundDrop({
|
||||
log: logVerbose,
|
||||
channel: "telegram",
|
||||
reason: "non-default account requires explicit binding",
|
||||
target: route.accountId,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
// Calculate groupAllowOverride first - it's needed for both DM and group allowlist checks
|
||||
const groupAllowOverride = firstDefined(topicConfig?.allowFrom, groupConfig?.allowFrom);
|
||||
// For DMs, prefer per-DM/topic allowFrom (groupAllowOverride) over account-level allowFrom
|
||||
const dmAllowFrom = groupAllowOverride ?? allowFrom;
|
||||
const effectiveDmAllow = normalizeDmAllowFromWithStore({
|
||||
allowFrom: dmAllowFrom,
|
||||
storeAllowFrom,
|
||||
dmPolicy: effectiveDmPolicy,
|
||||
});
|
||||
// Group sender checks are explicit and must not inherit DM pairing-store entries.
|
||||
const effectiveGroupAllow = normalizeAllowFrom(groupAllowOverride ?? groupAllowFrom);
|
||||
const hasGroupAllowOverride = typeof groupAllowOverride !== "undefined";
|
||||
const senderUsername = msg.from?.username ?? "";
|
||||
const baseAccess = evaluateTelegramGroupBaseAccess({
|
||||
isGroup,
|
||||
groupConfig,
|
||||
topicConfig,
|
||||
hasGroupAllowOverride,
|
||||
effectiveGroupAllow,
|
||||
senderId,
|
||||
senderUsername,
|
||||
enforceAllowOverride: true,
|
||||
requireSenderForAllowOverride: false,
|
||||
});
|
||||
if (!baseAccess.allowed) {
|
||||
if (baseAccess.reason === "group-disabled") {
|
||||
logVerbose(`Blocked telegram group ${chatId} (group disabled)`);
|
||||
return null;
|
||||
}
|
||||
if (baseAccess.reason === "topic-disabled") {
|
||||
logVerbose(
|
||||
`Blocked telegram topic ${chatId} (${resolvedThreadId ?? "unknown"}) (topic disabled)`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
logVerbose(
|
||||
isGroup
|
||||
? `Blocked telegram group sender ${senderId || "unknown"} (group allowFrom override)`
|
||||
: `Blocked telegram DM sender ${senderId || "unknown"} (DM allowFrom override)`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const requireTopic = (groupConfig as TelegramDirectConfig | undefined)?.requireTopic;
|
||||
const topicRequiredButMissing = !isGroup && requireTopic === true && dmThreadId == null;
|
||||
if (topicRequiredButMissing) {
|
||||
logVerbose(`Blocked telegram DM ${chatId}: requireTopic=true but no topic present`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const sendTyping = async () => {
|
||||
await withTelegramApiErrorLogging({
|
||||
operation: "sendChatAction",
|
||||
fn: () =>
|
||||
sendChatActionHandler.sendChatAction(
|
||||
chatId,
|
||||
"typing",
|
||||
buildTypingThreadParams(replyThreadId),
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
const sendRecordVoice = async () => {
|
||||
try {
|
||||
await withTelegramApiErrorLogging({
|
||||
operation: "sendChatAction",
|
||||
fn: () =>
|
||||
sendChatActionHandler.sendChatAction(
|
||||
chatId,
|
||||
"record_voice",
|
||||
buildTypingThreadParams(replyThreadId),
|
||||
),
|
||||
});
|
||||
} catch (err) {
|
||||
logVerbose(`telegram record_voice cue failed for chat ${chatId}: ${String(err)}`);
|
||||
}
|
||||
};
|
||||
|
||||
if (
|
||||
!(await enforceTelegramDmAccess({
|
||||
isGroup,
|
||||
dmPolicy: effectiveDmPolicy,
|
||||
msg,
|
||||
chatId,
|
||||
effectiveDmAllow,
|
||||
accountId: account.accountId,
|
||||
bot,
|
||||
logger,
|
||||
}))
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const ensureConfiguredBindingReady = async (): Promise<boolean> => {
|
||||
if (!configuredBinding) {
|
||||
return true;
|
||||
}
|
||||
const ensured = await ensureConfiguredAcpRouteReady({
|
||||
cfg: freshCfg,
|
||||
configuredBinding,
|
||||
});
|
||||
if (ensured.ok) {
|
||||
logVerbose(
|
||||
`telegram: using configured ACP binding for ${configuredBinding.spec.conversationId} -> ${configuredBindingSessionKey}`,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
logVerbose(
|
||||
`telegram: configured ACP binding unavailable for ${configuredBinding.spec.conversationId}: ${ensured.error}`,
|
||||
);
|
||||
logInboundDrop({
|
||||
log: logVerbose,
|
||||
channel: "telegram",
|
||||
reason: "configured ACP binding unavailable",
|
||||
target: configuredBinding.spec.conversationId,
|
||||
});
|
||||
return false;
|
||||
};
|
||||
|
||||
const baseSessionKey = isNamedAccountFallback
|
||||
? buildAgentSessionKey({
|
||||
agentId: route.agentId,
|
||||
channel: "telegram",
|
||||
accountId: route.accountId,
|
||||
peer: {
|
||||
kind: "direct",
|
||||
id: resolveTelegramDirectPeerId({
|
||||
chatId,
|
||||
senderId,
|
||||
}),
|
||||
},
|
||||
dmScope: "per-account-channel-peer",
|
||||
identityLinks: freshCfg.session?.identityLinks,
|
||||
}).toLowerCase()
|
||||
: route.sessionKey;
|
||||
// DMs: use thread suffix for session isolation (works regardless of dmScope)
|
||||
const threadKeys =
|
||||
dmThreadId != null
|
||||
? resolveThreadSessionKeys({ baseSessionKey, threadId: `${chatId}:${dmThreadId}` })
|
||||
: null;
|
||||
const sessionKey = threadKeys?.sessionKey ?? baseSessionKey;
|
||||
route = {
|
||||
...route,
|
||||
sessionKey,
|
||||
lastRoutePolicy: deriveLastRoutePolicy({
|
||||
sessionKey,
|
||||
mainSessionKey: route.mainSessionKey,
|
||||
}),
|
||||
};
|
||||
// Compute requireMention after access checks and final route selection.
|
||||
const activationOverride = resolveGroupActivation({
|
||||
chatId,
|
||||
messageThreadId: resolvedThreadId,
|
||||
sessionKey: sessionKey,
|
||||
agentId: route.agentId,
|
||||
});
|
||||
const baseRequireMention = resolveGroupRequireMention(chatId);
|
||||
const requireMention = firstDefined(
|
||||
activationOverride,
|
||||
topicConfig?.requireMention,
|
||||
(groupConfig as TelegramGroupConfig | undefined)?.requireMention,
|
||||
baseRequireMention,
|
||||
);
|
||||
|
||||
recordChannelActivity({
|
||||
channel: "telegram",
|
||||
accountId: account.accountId,
|
||||
direction: "inbound",
|
||||
});
|
||||
|
||||
const bodyResult = await resolveTelegramInboundBody({
|
||||
cfg,
|
||||
primaryCtx,
|
||||
msg,
|
||||
allMedia,
|
||||
isGroup,
|
||||
chatId,
|
||||
senderId,
|
||||
senderUsername,
|
||||
resolvedThreadId,
|
||||
routeAgentId: route.agentId,
|
||||
effectiveGroupAllow,
|
||||
effectiveDmAllow,
|
||||
groupConfig,
|
||||
topicConfig,
|
||||
requireMention,
|
||||
options,
|
||||
groupHistories,
|
||||
historyLimit,
|
||||
logger,
|
||||
});
|
||||
if (!bodyResult) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!(await ensureConfiguredBindingReady())) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// ACK reactions
|
||||
const ackReaction = resolveAckReaction(cfg, route.agentId, {
|
||||
channel: "telegram",
|
||||
accountId: account.accountId,
|
||||
});
|
||||
const removeAckAfterReply = cfg.messages?.removeAckAfterReply ?? false;
|
||||
const shouldAckReaction = () =>
|
||||
Boolean(
|
||||
ackReaction &&
|
||||
shouldAckReactionGate({
|
||||
scope: ackReactionScope,
|
||||
isDirect: !isGroup,
|
||||
isGroup,
|
||||
isMentionableGroup: isGroup,
|
||||
requireMention: Boolean(requireMention),
|
||||
canDetectMention: bodyResult.canDetectMention,
|
||||
effectiveWasMentioned: bodyResult.effectiveWasMentioned,
|
||||
shouldBypassMention: bodyResult.shouldBypassMention,
|
||||
}),
|
||||
);
|
||||
const api = bot.api as unknown as {
|
||||
setMessageReaction?: (
|
||||
chatId: number | string,
|
||||
messageId: number,
|
||||
reactions: Array<{ type: "emoji"; emoji: string }>,
|
||||
) => Promise<void>;
|
||||
getChat?: (chatId: number | string) => Promise<unknown>;
|
||||
};
|
||||
const reactionApi =
|
||||
typeof api.setMessageReaction === "function" ? api.setMessageReaction.bind(api) : null;
|
||||
const getChatApi = typeof api.getChat === "function" ? api.getChat.bind(api) : null;
|
||||
|
||||
// Status Reactions controller (lifecycle reactions)
|
||||
const statusReactionsConfig = cfg.messages?.statusReactions;
|
||||
const statusReactionsEnabled =
|
||||
statusReactionsConfig?.enabled === true && Boolean(reactionApi) && shouldAckReaction();
|
||||
const resolvedStatusReactionEmojis = resolveTelegramStatusReactionEmojis({
|
||||
initialEmoji: ackReaction,
|
||||
overrides: statusReactionsConfig?.emojis,
|
||||
});
|
||||
const statusReactionVariantsByEmoji = buildTelegramStatusReactionVariants(
|
||||
resolvedStatusReactionEmojis,
|
||||
);
|
||||
let allowedStatusReactionEmojisPromise: Promise<Set<string> | null> | null = null;
|
||||
const statusReactionController: StatusReactionController | null =
|
||||
statusReactionsEnabled && msg.message_id
|
||||
? createStatusReactionController({
|
||||
enabled: true,
|
||||
adapter: {
|
||||
setReaction: async (emoji: string) => {
|
||||
if (reactionApi) {
|
||||
if (!allowedStatusReactionEmojisPromise) {
|
||||
allowedStatusReactionEmojisPromise = resolveTelegramAllowedEmojiReactions({
|
||||
chat: msg.chat,
|
||||
chatId,
|
||||
getChat: getChatApi ?? undefined,
|
||||
}).catch((err) => {
|
||||
logVerbose(
|
||||
`telegram status-reaction available_reactions lookup failed for chat ${chatId}: ${String(err)}`,
|
||||
);
|
||||
return null;
|
||||
});
|
||||
}
|
||||
const allowedStatusReactionEmojis = await allowedStatusReactionEmojisPromise;
|
||||
const resolvedEmoji = resolveTelegramReactionVariant({
|
||||
requestedEmoji: emoji,
|
||||
variantsByRequestedEmoji: statusReactionVariantsByEmoji,
|
||||
allowedEmojiReactions: allowedStatusReactionEmojis,
|
||||
});
|
||||
if (!resolvedEmoji) {
|
||||
return;
|
||||
}
|
||||
await reactionApi(chatId, msg.message_id, [
|
||||
{ type: "emoji", emoji: resolvedEmoji },
|
||||
]);
|
||||
}
|
||||
},
|
||||
// Telegram replaces atomically — no removeReaction needed
|
||||
},
|
||||
initialEmoji: ackReaction,
|
||||
emojis: resolvedStatusReactionEmojis,
|
||||
timing: statusReactionsConfig?.timing,
|
||||
onError: (err) => {
|
||||
logVerbose(`telegram status-reaction error for chat ${chatId}: ${String(err)}`);
|
||||
},
|
||||
})
|
||||
: null;
|
||||
|
||||
// When status reactions are enabled, setQueued() replaces the simple ack reaction
|
||||
const ackReactionPromise = statusReactionController
|
||||
? shouldAckReaction()
|
||||
? Promise.resolve(statusReactionController.setQueued()).then(
|
||||
() => true,
|
||||
() => false,
|
||||
)
|
||||
: null
|
||||
: shouldAckReaction() && msg.message_id && reactionApi
|
||||
? withTelegramApiErrorLogging({
|
||||
operation: "setMessageReaction",
|
||||
fn: () => reactionApi(chatId, msg.message_id, [{ type: "emoji", emoji: ackReaction }]),
|
||||
}).then(
|
||||
() => true,
|
||||
(err) => {
|
||||
logVerbose(`telegram react failed for chat ${chatId}: ${String(err)}`);
|
||||
return false;
|
||||
},
|
||||
)
|
||||
: null;
|
||||
|
||||
const { ctxPayload, skillFilter } = await buildTelegramInboundContextPayload({
|
||||
cfg,
|
||||
primaryCtx,
|
||||
msg,
|
||||
allMedia,
|
||||
replyMedia,
|
||||
isGroup,
|
||||
isForum,
|
||||
chatId,
|
||||
senderId,
|
||||
senderUsername,
|
||||
resolvedThreadId,
|
||||
dmThreadId,
|
||||
threadSpec,
|
||||
route,
|
||||
rawBody: bodyResult.rawBody,
|
||||
bodyText: bodyResult.bodyText,
|
||||
historyKey: bodyResult.historyKey,
|
||||
historyLimit,
|
||||
groupHistories,
|
||||
groupConfig,
|
||||
topicConfig,
|
||||
stickerCacheHit: bodyResult.stickerCacheHit,
|
||||
effectiveWasMentioned: bodyResult.effectiveWasMentioned,
|
||||
locationData: bodyResult.locationData,
|
||||
options,
|
||||
dmAllowFrom,
|
||||
commandAuthorized: bodyResult.commandAuthorized,
|
||||
});
|
||||
|
||||
return {
|
||||
ctxPayload,
|
||||
primaryCtx,
|
||||
msg,
|
||||
chatId,
|
||||
isGroup,
|
||||
resolvedThreadId,
|
||||
threadSpec,
|
||||
replyThreadId,
|
||||
isForum,
|
||||
historyKey: bodyResult.historyKey,
|
||||
historyLimit,
|
||||
groupHistories,
|
||||
route,
|
||||
skillFilter,
|
||||
sendTyping,
|
||||
sendRecordVoice,
|
||||
ackReactionPromise,
|
||||
reactionApi,
|
||||
removeAckAfterReply,
|
||||
statusReactionController,
|
||||
accountId: account.accountId,
|
||||
};
|
||||
};
|
||||
|
||||
export type TelegramMessageContext = NonNullable<
|
||||
Awaited<ReturnType<typeof buildTelegramMessageContext>>
|
||||
>;
|
||||
Reference in New Issue
Block a user