diff --git a/src/discord/monitor/listeners.ts b/src/discord/monitor/listeners.ts index 20cc76aa3..244fba304 100644 --- a/src/discord/monitor/listeners.ts +++ b/src/discord/monitor/listeners.ts @@ -4,23 +4,36 @@ import { MessageCreateListener, MessageReactionAddListener, MessageReactionRemoveListener, + MessageUpdateListener, PresenceUpdateListener, type User, } from "@buape/carbon"; +import type { DmPolicy, GroupPolicy } from "../../config/types.base.js"; import { danger } from "../../globals.js"; import { formatDurationSeconds } from "../../infra/format-time/format-duration.ts"; import { enqueueSystemEvent } from "../../infra/system-events.js"; import { createSubsystemLogger } from "../../logging/subsystem.js"; +import { readChannelAllowFromStore } from "../../pairing/pairing-store.js"; import { resolveAgentRoute } from "../../routing/resolve-route.js"; import { + allowListMatches, + isDiscordGroupAllowedByPolicy, + normalizeDiscordAllowList, normalizeDiscordSlug, resolveDiscordChannelConfigWithFallback, resolveDiscordGuildEntry, + resolveDiscordMemberAccessState, + resolveGroupDmAllow, shouldEmitDiscordReactionNotification, } from "./allow-list.js"; -import { formatDiscordReactionEmoji, formatDiscordUserTag } from "./format.js"; -import { resolveDiscordChannelInfo } from "./message-utils.js"; +import { + formatDiscordReactionEmoji, + formatDiscordUserTag, + resolveDiscordSystemLocation, +} from "./format.js"; +import { resolveDiscordChannelInfo, resolveDiscordMessageChannelId } from "./message-utils.js"; import { setPresence } from "./presence-cache.js"; +import { resolveDiscordThreadChannel, resolveDiscordThreadParentInfo } from "./threading.js"; type LoadedConfig = ReturnType; type RuntimeEnv = import("../../runtime.js").RuntimeEnv; @@ -30,6 +43,8 @@ export type DiscordMessageEvent = Parameters[0] export type DiscordMessageHandler = (data: DiscordMessageEvent, client: Client) => Promise; +export type DiscordMessageUpdateEvent = Parameters[0]; + type DiscordReactionEvent = Parameters[0]; type DiscordReactionListenerParams = { @@ -41,6 +56,16 @@ type DiscordReactionListenerParams = { logger: Logger; }; +type DiscordMessageUpdateListenerParams = DiscordReactionListenerParams & { + dmEnabled: boolean; + dmPolicy: DmPolicy; + allowFrom?: string[]; + groupPolicy: GroupPolicy; + groupDmEnabled: boolean; + groupDmChannels?: string[]; + allowBots: boolean; +}; + const DISCORD_SLOW_LISTENER_THRESHOLD_MS = 30_000; const discordEventQueueLog = createSubsystemLogger("discord/event-queue"); @@ -103,6 +128,22 @@ export class DiscordMessageListener extends MessageCreateListener { } } +export class DiscordMessageUpdateListener extends MessageUpdateListener { + constructor(private params: DiscordMessageUpdateListenerParams) { + super(); + } + + async handle(data: DiscordMessageUpdateEvent, client: Client) { + await runDiscordMessageUpdateHandler({ + data, + client, + handlerParams: this.params, + listener: this.constructor.name, + event: this.type, + }); + } +} + export class DiscordReactionListener extends MessageReactionAddListener { constructor(private params: DiscordReactionListenerParams) { super(); @@ -137,6 +178,30 @@ export class DiscordReactionRemoveListener extends MessageReactionRemoveListener } } +async function runDiscordMessageUpdateHandler(params: { + data: DiscordMessageUpdateEvent; + client: Client; + handlerParams: DiscordMessageUpdateListenerParams; + listener: string; + event: string; +}): Promise { + const startedAt = Date.now(); + try { + await handleDiscordMessageUpdateEvent({ + data: params.data, + client: params.client, + handlerParams: params.handlerParams, + }); + } finally { + logSlowDiscordListener({ + logger: params.handlerParams.logger, + listener: params.listener, + event: params.event, + durationMs: Date.now() - startedAt, + }); + } +} + async function runDiscordReactionHandler(params: { data: DiscordReactionEvent; client: Client; @@ -167,6 +232,223 @@ async function runDiscordReactionHandler(params: { } } +async function handleDiscordMessageUpdateEvent(params: { + data: DiscordMessageUpdateEvent; + client: Client; + handlerParams: DiscordMessageUpdateListenerParams; +}) { + const { data, client, handlerParams } = params; + try { + const message = data.message; + if (!message) { + return; + } + const editedTimestamp = + message.editedTimestamp ?? + (data as { edited_timestamp?: string | null }).edited_timestamp ?? + null; + if (!editedTimestamp) { + return; + } + + const author = + message.author ?? (message as { rawData?: { author?: User | null } }).rawData?.author; + const authorId = author?.id ? String(author.id) : ""; + if (handlerParams.botUserId && authorId && authorId === handlerParams.botUserId) { + return; + } + if (author?.bot && !handlerParams.allowBots) { + return; + } + + const messageChannelId = resolveDiscordMessageChannelId({ + message, + eventChannelId: data.channel_id, + }); + if (!messageChannelId) { + return; + } + + const channelInfo = await resolveDiscordChannelInfo(client, messageChannelId); + const isGuildMessage = Boolean(data.guild_id); + if (!channelInfo && !isGuildMessage) { + return; + } + + const isDirectMessage = channelInfo?.type === ChannelType.DM; + const isGroupDm = channelInfo?.type === ChannelType.GroupDM; + + if (isDirectMessage) { + if (!handlerParams.dmEnabled) { + return; + } + if (handlerParams.dmPolicy === "disabled") { + return; + } + if (!authorId) { + return; + } + if (handlerParams.dmPolicy !== "open") { + const storeAllowFrom = await readChannelAllowFromStore("discord").catch(() => []); + const effectiveAllowFrom = [...(handlerParams.allowFrom ?? []), ...storeAllowFrom]; + const allowList = normalizeDiscordAllowList(effectiveAllowFrom, [ + "discord:", + "user:", + "pk:", + ]); + if (!allowList) { + return; + } + const authorTag = author ? formatDiscordUserTag(author as User) : undefined; + const allowed = allowListMatches(allowList, { + id: authorId, + name: author?.username ?? undefined, + tag: authorTag, + }); + if (!allowed) { + return; + } + } + } + + if (isGroupDm) { + if (!handlerParams.groupDmEnabled) { + return; + } + const channelName = channelInfo?.name ?? undefined; + const displayChannelName = channelName ?? messageChannelId; + const displayChannelSlug = displayChannelName ? normalizeDiscordSlug(displayChannelName) : ""; + const groupDmAllowed = resolveGroupDmAllow({ + channels: handlerParams.groupDmChannels, + channelId: messageChannelId, + channelName: displayChannelName, + channelSlug: displayChannelSlug, + }); + if (!groupDmAllowed) { + return; + } + } + + let threadParentId: string | undefined; + let threadParentName: string | undefined; + const threadChannel = resolveDiscordThreadChannel({ + isGuildMessage, + message, + channelInfo, + messageChannelId, + }); + if (threadChannel) { + const parentInfo = await resolveDiscordThreadParentInfo({ + client, + threadChannel, + channelInfo, + }); + threadParentId = parentInfo.id; + threadParentName = parentInfo.name; + } + + const guildInfo = isGuildMessage + ? resolveDiscordGuildEntry({ + guild: data.guild ?? undefined, + guildEntries: handlerParams.guildEntries, + }) + : null; + if ( + isGuildMessage && + handlerParams.guildEntries && + Object.keys(handlerParams.guildEntries).length > 0 && + !guildInfo + ) { + return; + } + + const channelName = channelInfo?.name ?? threadChannel?.name ?? undefined; + const channelSlug = channelName ? normalizeDiscordSlug(channelName) : ""; + const parentSlug = threadParentName ? normalizeDiscordSlug(threadParentName) : ""; + const channelConfig = isGuildMessage + ? resolveDiscordChannelConfigWithFallback({ + guildInfo, + channelId: messageChannelId, + channelName, + channelSlug, + parentId: threadParentId, + parentName: threadParentName, + parentSlug, + scope: threadChannel ? "thread" : "channel", + }) + : null; + + if (isGuildMessage && channelConfig?.enabled === false) { + return; + } + + const channelAllowlistConfigured = + Boolean(guildInfo?.channels) && Object.keys(guildInfo?.channels ?? {}).length > 0; + const channelAllowed = channelConfig?.allowed !== false; + if ( + isGuildMessage && + !isDiscordGroupAllowedByPolicy({ + groupPolicy: handlerParams.groupPolicy, + guildAllowlisted: Boolean(guildInfo), + channelAllowlistConfigured, + channelAllowed, + }) + ) { + return; + } + if (isGuildMessage && channelConfig?.allowed === false) { + return; + } + + const memberRoles = (data as { member?: { roles?: string[] } }).member?.roles; + const memberRoleIds = Array.isArray(memberRoles) + ? memberRoles.map((roleId) => String(roleId)) + : []; + + const senderTag = author ? formatDiscordUserTag(author as User) : undefined; + const { hasAccessRestrictions, memberAllowed } = resolveDiscordMemberAccessState({ + channelConfig, + guildInfo, + memberRoleIds, + sender: { + id: authorId, + name: author?.username ?? undefined, + tag: senderTag, + }, + }); + if (isGuildMessage && hasAccessRestrictions && !memberAllowed) { + return; + } + + const route = resolveAgentRoute({ + cfg: handlerParams.cfg, + channel: "discord", + accountId: handlerParams.accountId, + guildId: data.guild_id ?? undefined, + memberRoleIds, + peer: { + kind: isDirectMessage ? "direct" : isGroupDm ? "group" : "channel", + id: isDirectMessage ? authorId : messageChannelId, + }, + parentPeer: threadParentId ? { kind: "channel", id: threadParentId } : undefined, + }); + + const location = resolveDiscordSystemLocation({ + isDirectMessage, + isGroupDm, + guild: data.guild ?? undefined, + channelName: channelName ?? messageChannelId, + }); + const text = `Discord message edited in ${location}.`; + enqueueSystemEvent(text, { + sessionKey: route.sessionKey, + contextKey: `discord:message:edited:${messageChannelId}:${message.id}`, + }); + } catch (err) { + handlerParams.logger.error(danger(`discord message update handler failed: ${String(err)}`)); + } +} + async function handleDiscordReactionEvent(params: { data: DiscordReactionEvent; client: Client; diff --git a/src/discord/monitor/message-update.test.ts b/src/discord/monitor/message-update.test.ts new file mode 100644 index 000000000..753ea51c2 --- /dev/null +++ b/src/discord/monitor/message-update.test.ts @@ -0,0 +1,113 @@ +import { ChannelType } from "@buape/carbon"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../../config/config.js"; +import { enqueueSystemEvent } from "../../infra/system-events.js"; +import { DiscordMessageUpdateListener, type DiscordMessageUpdateEvent } from "./listeners.js"; +import { __resetDiscordChannelInfoCacheForTest } from "./message-utils.js"; + +vi.mock("../../infra/system-events.js", () => ({ + enqueueSystemEvent: vi.fn(), +})); + +describe("DiscordMessageUpdateListener", () => { + const enqueueSystemEventMock = vi.mocked(enqueueSystemEvent); + + beforeEach(() => { + enqueueSystemEventMock.mockReset(); + __resetDiscordChannelInfoCacheForTest(); + }); + + it("enqueues system event for edited DMs", async () => { + const cfg = { channels: { discord: {} } } as OpenClawConfig; + const listener = new DiscordMessageUpdateListener({ + cfg, + accountId: "default", + runtime: { error: vi.fn() } as unknown as import("../../runtime.js").RuntimeEnv, + botUserId: "bot-1", + guildEntries: undefined, + logger: { error: vi.fn(), warn: vi.fn() } as unknown as ReturnType< + typeof import("../../logging/subsystem.js").createSubsystemLogger + >, + dmEnabled: true, + dmPolicy: "open", + allowFrom: [], + groupPolicy: "open", + groupDmEnabled: false, + groupDmChannels: undefined, + allowBots: false, + }); + + const message = { + id: "msg-1", + channelId: "dm-1", + editedTimestamp: "2026-02-20T00:00:00.000Z", + author: { id: "user-1", username: "Ada", discriminator: "0001", bot: false }, + } as unknown as import("@buape/carbon").Message; + + const client = { + fetchChannel: vi.fn(async () => ({ type: ChannelType.DM })), + } as unknown as import("@buape/carbon").Client; + + await listener.handle( + { + channel_id: "dm-1", + message, + } as DiscordMessageUpdateEvent, + client, + ); + + expect(enqueueSystemEventMock).toHaveBeenCalledWith( + "Discord message edited in DM.", + expect.objectContaining({ + contextKey: "discord:message:edited:dm-1:msg-1", + }), + ); + }); + + it("skips system event when guild allowlist blocks sender", async () => { + const cfg = { channels: { discord: {} } } as OpenClawConfig; + const listener = new DiscordMessageUpdateListener({ + cfg, + accountId: "default", + runtime: { error: vi.fn() } as unknown as import("../../runtime.js").RuntimeEnv, + botUserId: "bot-1", + guildEntries: { + "guild-1": { users: ["user-allowed"] }, + }, + logger: { error: vi.fn(), warn: vi.fn() } as unknown as ReturnType< + typeof import("../../logging/subsystem.js").createSubsystemLogger + >, + dmEnabled: true, + dmPolicy: "open", + allowFrom: [], + groupPolicy: "open", + groupDmEnabled: false, + groupDmChannels: undefined, + allowBots: false, + }); + + const message = { + id: "msg-2", + channelId: "channel-1", + editedTimestamp: "2026-02-20T00:00:00.000Z", + author: { id: "user-blocked", username: "Ada", discriminator: "0001", bot: false }, + } as unknown as import("@buape/carbon").Message; + + const client = { + fetchChannel: vi.fn(async () => ({ type: ChannelType.GuildText })), + } as unknown as import("@buape/carbon").Client; + + await listener.handle( + { + channel_id: "channel-1", + guild_id: "guild-1", + guild: { id: "guild-1", name: "Test Guild" }, + member: { roles: [] }, + message, + } as DiscordMessageUpdateEvent, + client, + ); + + expect(enqueueSystemEventMock).not.toHaveBeenCalled(); + }); +}); diff --git a/src/discord/monitor/provider.ts b/src/discord/monitor/provider.ts index 3a44da898..e3c0a2096 100644 --- a/src/discord/monitor/provider.ts +++ b/src/discord/monitor/provider.ts @@ -59,6 +59,7 @@ import { createDiscordGatewayPlugin } from "./gateway-plugin.js"; import { registerGateway, unregisterGateway } from "./gateway-registry.js"; import { DiscordMessageListener, + DiscordMessageUpdateListener, DiscordPresenceListener, DiscordReactionListener, DiscordReactionRemoveListener, @@ -605,6 +606,24 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { }); registerDiscordListener(client.listeners, new DiscordMessageListener(messageHandler, logger)); + registerDiscordListener( + client.listeners, + new DiscordMessageUpdateListener({ + cfg, + accountId: account.accountId, + runtime, + botUserId, + guildEntries, + logger, + dmEnabled, + dmPolicy, + allowFrom, + groupPolicy, + groupDmEnabled, + groupDmChannels, + allowBots: discordCfg.allowBots ?? false, + }), + ); registerDiscordListener( client.listeners, new DiscordReactionListener({ diff --git a/src/telegram/bot-handlers.ts b/src/telegram/bot-handlers.ts index 1e51e0dbc..d184cd5f1 100644 --- a/src/telegram/bot-handlers.ts +++ b/src/telegram/bot-handlers.ts @@ -453,6 +453,140 @@ export const registerTelegramHandlers = ({ return false; }; + const buildTelegramEditSenderLabel = (msg: Message) => { + const senderChat = msg.sender_chat; + const senderName = + [msg.from?.first_name, msg.from?.last_name].filter(Boolean).join(" ").trim() || + msg.from?.username || + senderChat?.title || + senderChat?.username; + const senderUsername = msg.from?.username ?? senderChat?.username; + const senderUsernameLabel = senderUsername ? `@${senderUsername}` : undefined; + let senderLabel = senderName; + if (senderName && senderUsernameLabel) { + senderLabel = `${senderName} (${senderUsernameLabel})`; + } else if (!senderName && senderUsernameLabel) { + senderLabel = senderUsernameLabel; + } + const senderId = msg.from?.id ?? senderChat?.id; + if (!senderLabel && senderId != null) { + senderLabel = `id:${senderId}`; + } + return senderLabel || "unknown"; + }; + + const handleTelegramEditedMessage = async (params: { + ctx: TelegramUpdateKeyContext; + msg: Message; + requireConfiguredGroup: boolean; + }) => { + try { + if (shouldSkipUpdate(params.ctx)) { + return; + } + + const msg = params.msg; + if (msg.from?.is_bot) { + return; + } + + const chatId = msg.chat.id; + const isChannelPost = msg.chat.type === "channel"; + const isGroup = msg.chat.type === "group" || msg.chat.type === "supergroup" || isChannelPost; + const isForum = msg.chat.is_forum === true; + const messageThreadId = msg.message_thread_id; + const senderId = + msg.from?.id != null + ? String(msg.from.id) + : msg.sender_chat?.id != null + ? String(msg.sender_chat.id) + : String(chatId); + const senderUsername = msg.from?.username ?? msg.sender_chat?.username ?? ""; + const groupAllowContext = await resolveTelegramGroupAllowFromContext({ + chatId, + accountId, + isForum, + messageThreadId, + groupAllowFrom, + resolveTelegramGroupConfig, + }); + const { + resolvedThreadId, + storeAllowFrom, + groupConfig, + topicConfig, + effectiveGroupAllow, + hasGroupAllowOverride, + } = groupAllowContext; + + if (params.requireConfiguredGroup && (!groupConfig || groupConfig.enabled === false)) { + logVerbose(`Blocked telegram channel ${chatId} (channel disabled)`); + return; + } + + if ( + shouldSkipGroupMessage({ + isGroup, + chatId, + chatTitle: msg.chat.title, + resolvedThreadId, + senderId, + senderUsername, + effectiveGroupAllow, + hasGroupAllowOverride, + groupConfig, + topicConfig, + }) + ) { + return; + } + + if (!isGroup) { + const dmPolicy = telegramCfg.dmPolicy ?? "pairing"; + if (dmPolicy === "disabled") { + return; + } + const effectiveDmAllow = normalizeAllowFromWithStore({ + allowFrom: telegramCfg.allowFrom, + storeAllowFrom, + }); + if (dmPolicy !== "open") { + const allowed = isAllowlistAuthorized(effectiveDmAllow, senderId, senderUsername); + if (!allowed) { + return; + } + } + } + + const peerId = isGroup ? buildTelegramGroupPeerId(chatId, resolvedThreadId) : String(chatId); + const parentPeer = buildTelegramParentPeer({ isGroup, resolvedThreadId, chatId }); + const route = resolveAgentRoute({ + cfg: loadConfig(), + channel: "telegram", + accountId, + peer: { kind: isGroup ? "group" : "direct", id: peerId }, + parentPeer, + }); + const sessionKey = route.sessionKey; + const senderLabel = buildTelegramEditSenderLabel(msg); + const chatLabel = isGroup + ? msg.chat.title?.trim() || (isChannelPost ? "Telegram channel" : "Telegram group") + : senderLabel !== "unknown" + ? `DM with ${senderLabel}` + : "DM"; + const text = `Telegram message edited in ${chatLabel}.`; + enqueueSystemEvent(text, { + sessionKey, + contextKey: `telegram:message:edited:${chatId}:${resolvedThreadId ?? "main"}:${ + msg.message_id + }`, + }); + logVerbose(`telegram: edit event enqueued: ${text}`); + } catch (err) { + runtime.error?.(danger(`telegram edit handler failed: ${String(err)}`)); + } + }; + // Handle emoji reactions to messages. bot.on("message_reaction", async (ctx) => { try { @@ -544,6 +678,35 @@ export const registerTelegramHandlers = ({ runtime.error?.(danger(`telegram reaction handler failed: ${String(err)}`)); } }); + + bot.on("edited_message", async (ctx) => { + const msg = + (ctx as { editedMessage?: Message }).editedMessage ?? ctx.update?.edited_message ?? undefined; + if (!msg) { + return; + } + await handleTelegramEditedMessage({ + ctx, + msg, + requireConfiguredGroup: false, + }); + }); + + bot.on("edited_channel_post", async (ctx) => { + const msg = + (ctx as { editedChannelPost?: Message }).editedChannelPost ?? + ctx.update?.edited_channel_post ?? + undefined; + if (!msg) { + return; + } + await handleTelegramEditedMessage({ + ctx, + msg, + requireConfiguredGroup: true, + }); + }); + const processInboundMessage = async (params: { ctx: TelegramContext; msg: Message; diff --git a/src/telegram/bot.test.ts b/src/telegram/bot.test.ts index 6c3776619..db5a33814 100644 --- a/src/telegram/bot.test.ts +++ b/src/telegram/bot.test.ts @@ -746,6 +746,43 @@ describe("createTelegramBot", () => { expect(reactionHandler).toBeDefined(); }); + it("enqueues system event for edited messages", async () => { + onSpy.mockReset(); + enqueueSystemEventSpy.mockReset(); + + loadConfig.mockReturnValue({ + channels: { + telegram: { dmPolicy: "open" }, + }, + }); + + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("edited_message") as ( + ctx: Record, + ) => Promise; + + const editedMessage = { + chat: { id: 1234, type: "private" }, + message_id: 88, + from: { id: 9, first_name: "Ada", username: "ada_bot" }, + date: 1736380800, + text: "edited", + }; + + await handler({ + update: { update_id: 550, edited_message: editedMessage }, + editedMessage, + }); + + expect(enqueueSystemEventSpy).toHaveBeenCalledTimes(1); + expect(enqueueSystemEventSpy).toHaveBeenCalledWith( + "Telegram message edited in DM with Ada (@ada_bot).", + expect.objectContaining({ + contextKey: expect.stringContaining("telegram:message:edited:1234:main:88"), + }), + ); + }); + it("enqueues system event for reaction", async () => { onSpy.mockReset(); enqueueSystemEventSpy.mockReset();