diff --git a/docs/channels/discord.md b/docs/channels/discord.md index 91979966345e..cff753ea1f70 100644 --- a/docs/channels/discord.md +++ b/docs/channels/discord.md @@ -1141,10 +1141,30 @@ Discord message actions cover messaging, channel admin, moderation, presence, an Core examples: - messaging: `sendMessage`, `readMessages`, `editMessage`, `deleteMessage`, `threadReply` -- reactions: `react`, `reactions`, `emojiList` +- reactions: `react`, `reactions`, `emoji-list` - moderation: `timeout`, `kick`, `ban` - presence: `setPresence` +Use `emoji-list` to discover the current server's custom emoji: + +```json +{ "action": "emoji-list", "channel": "discord", "limit": 25 } +``` + +`guildId` defaults to the current conversation's server; provide it explicitly to query another server. Results are sorted by name, and `limit` defaults to and cannot exceed 100: + +```json +{ + "ok": true, + "emojis": [ + { "name": "dance", "identifier": "dance:456", "animated": true }, + { "name": "party", "identifier": "party:123" } + ] +} +``` + +Pass `identifier` directly to `react`. Discord accepts Unicode emoji, custom `name:id` identifiers, and the `<:name:id>` or `` forms. `emoji-list`, `react`, and `reactions` are all controlled by `channels.discord.actions.reactions`. + The `event-create` action accepts an optional `image` parameter (URL or local file path) to set the scheduled event cover image. Action gates live under `channels.discord.actions.*`. diff --git a/docs/channels/slack.md b/docs/channels/slack.md index aecba8f4c41b..13aba6f867a9 100644 --- a/docs/channels/slack.md +++ b/docs/channels/slack.md @@ -1271,6 +1271,26 @@ Available action groups in current Slack tooling: Current Slack message actions include `send`, `upload-file`, `download-file`, `read`, `edit`, `delete`, `pin`, `unpin`, `list-pins`, `member-info`, and `emoji-list`. `download-file` accepts Slack file IDs shown in inbound file placeholders and returns image previews for images or local file metadata for other file types. +Use `emoji-list` to discover workspace custom emoji and aliases: + +```json +{ "action": "emoji-list", "channel": "slack", "limit": 25 } +``` + +Results are sorted by shortcode name. `limit` defaults to and cannot exceed 100: + +```json +{ + "ok": true, + "emojis": [ + { "name": "celebrate", "identifier": "celebrate", "aliasOf": "party" }, + { "name": "party", "identifier": "party" } + ] +} +``` + +Use an entry's `identifier` directly as the `react` emoji; surrounding colons are optional. `channels.slack.actions.emojiList` controls discovery separately from the `reactions` gate, and the app needs the `emoji:read` scope. + ## Access control and routing diff --git a/docs/channels/telegram.md b/docs/channels/telegram.md index fb6374112446..a792e8161d76 100644 --- a/docs/channels/telegram.md +++ b/docs/channels/telegram.md @@ -583,15 +583,30 @@ curl "https://api.telegram.org/bot/getUpdates" - `sendMessage` (`to`, `content`, optional `mediaUrl`, `replyToMessageId`, `messageThreadId`) - `react` (`chatId`, `messageId`, `emoji`) + - `emoji-list` (optional `chatId`, `limit`) - `deleteMessage` (`chatId`, `messageId`) - `editMessage` (`chatId`, `messageId`, `content` or `caption`, optional `presentation` inline buttons; button-only edits update reply markup) - `createForumTopic` (`chatId`, `name`, optional `iconColor`, `iconCustomEmojiId`) Ergonomic aliases: `send`, `react`, `delete`, `edit`, `sticker`, `sticker-search`, `topic-create`. - Gating: `channels.telegram.actions.sendMessage`, `deleteMessage`, `reactions`, `sticker` (default: disabled). `edit`, `createForumTopic`, and `editForumTopic` are enabled by default with no dedicated toggle. + Gating: `channels.telegram.actions.sendMessage`, `deleteMessage`, `reactions`, `sticker` (default: disabled). `reactions` controls both `react` and `emoji-list`. `edit`, `createForumTopic`, and `editForumTopic` are enabled by default with no dedicated toggle. Runtime sends use the active config/secrets snapshot from startup/reload, so action paths do not re-resolve `SecretRef` values per send. + Use `emoji-list` to inspect reactions in the current trusted chat and account. Agents cannot inspect another chat; direct operators may provide a different `chatId`. `limit` defaults to and cannot exceed 100: + +```json +{ + "ok": true, + "emojis": [ + { "name": "👍", "identifier": "👍" }, + { "identifier": "5368324170671202286", "type": "custom_emoji" } + ] +} +``` + + Pass a Unicode identifier or numeric custom emoji identifier directly to `react`. Chats without reaction restrictions return the known standard Telegram reactions and a `note` explaining that all standard reactions are allowed. When Telegram rejects a reaction and the chat's allowed Unicode reactions are known, the error includes a short sample of valid alternatives. + Reaction removal semantics: [/tools/reactions](/tools/reactions). diff --git a/docs/gateway/config-channels.md b/docs/gateway/config-channels.md index 1fba0940409b..eb2ca218fca9 100644 --- a/docs/gateway/config-channels.md +++ b/docs/gateway/config-channels.md @@ -216,6 +216,7 @@ WhatsApp runs through the gateway's web channel (Baileys Web). It starts automat - Optional `channels.telegram.defaultAccount` overrides default account selection when it matches a configured account id. - In multi-account setups (2+ account ids), set an explicit default (`channels.telegram.defaultAccount` or `channels.telegram.accounts.default`) to avoid fallback routing; `openclaw doctor` warns when this is missing or invalid. - `configWrites: false` blocks Telegram-initiated config writes (supergroup ID migrations, `/config set|unset`). +- `actions.reactions` controls both message reactions and `emoji-list`, which lists the standard and custom reactions allowed in the current chat. - Top-level `bindings[]` entries with `type: "acp"` configure persistent ACP bindings for forum topics (use canonical `chatId:topic:topicId` in `match.peer.id`). Field semantics are shared in [ACP Agents](/tools/acp-agents#persistent-channel-bindings). - Telegram stream previews use `sendMessage` + `editMessageText` (works in direct and group chats). - `network.dnsResultOrder` defaults to `"ipv4first"` to avoid common IPv6 fetch failures. @@ -327,6 +328,7 @@ WhatsApp runs through the gateway's web channel (Baileys Web). It starts automat - Direct outbound calls that provide an explicit Discord `token` use that token for the call; account policy settings still come from the selected account in the active runtime snapshot. - Optional `channels.discord.defaultAccount` overrides default account selection when it matches a configured account id. - Use `user:` (DM) or `channel:` (guild channel) for delivery targets; bare numeric IDs are rejected. +- `actions.reactions` controls `react`, `reactions`, and `emoji-list`; emoji discovery defaults to the current server unless `guildId` is provided. - Guild slugs are lowercase with spaces replaced by `-`; channel keys use the slugged name (no `#`). Prefer guild IDs. - Bot-authored messages are ignored by default. `allowBots: true` enables them; use `allowBots: "mentions"` to only accept bot messages that mention the bot (own messages still filtered). - Channels that support bot-authored inbound messages can use shared [bot loop protection](/channels/bot-loop-protection). Set `channels.defaults.botLoopProtection` for baseline pair budgets, then override the channel or account only when one surface needs different limits. @@ -515,7 +517,7 @@ WhatsApp runs through the gateway's web channel (Baileys Web). It starts automat | messages | enabled | Read/send/edit/delete | | pins | enabled | Pin/unpin/list | | memberInfo | enabled | Member info | -| emojiList | enabled | Custom emoji list | +| emojiList | enabled | List custom emoji | ### Mattermost diff --git a/extensions/discord/src/actions/handle-action.guild-admin.ts b/extensions/discord/src/actions/handle-action.guild-admin.ts index e2265a16b4f6..d6ba9106f848 100644 --- a/extensions/discord/src/actions/handle-action.guild-admin.ts +++ b/extensions/discord/src/actions/handle-action.guild-admin.ts @@ -87,11 +87,15 @@ export async function tryHandleDiscordMessageActionGuildAdmin(params: { } if (action === "emoji-list") { - const guildId = readStringParam(actionParams, "guildId", { - required: true, - }); + const guildId = readStringParam(actionParams, "guildId"); + const limit = readPositiveIntegerParam(actionParams, "limit"); return await handleDiscordAction( - { action: "emojiList", accountId: accountId ?? undefined, guildId }, + { + action: "emojiList", + accountId: accountId ?? undefined, + ...(guildId ? { guildId } : { channelId: resolveChannelId() }), + ...(limit ? { limit } : {}), + }, cfg, readPolicyOptions, ); diff --git a/extensions/discord/src/actions/runtime.guild.ts b/extensions/discord/src/actions/runtime.guild.ts index 0e851e7f3266..03d650ad1377 100644 --- a/extensions/discord/src/actions/runtime.guild.ts +++ b/extensions/discord/src/actions/runtime.guild.ts @@ -7,6 +7,7 @@ import { type ActionGate, jsonResult, readNonNegativeIntegerParam, + readPositiveIntegerParam, readStringArrayParam, readStringParam, type DiscordActionConfig, @@ -376,11 +377,23 @@ export async function handleDiscordGuildAction( if (!isActionEnabled("reactions")) { throw new Error("Discord reactions are disabled."); } - const guildId = readStringParam(params, "guildId", { - required: true, - }); + const guildId = await resolveGuildIdForGuildAdminAction({ values: params, accountId, cfg }); + if (!guildId) { + throw new Error("Discord emoji listing requires guildId or a server channel."); + } await assertGuildMetadataReadAllowed(guildId); - const emojis = await discordGuildActionRuntime.listGuildEmojisDiscord(guildId, withOpts()); + const limit = Math.min(readPositiveIntegerParam(params, "limit") ?? 100, 100); + const emojis = (await discordGuildActionRuntime.listGuildEmojisDiscord(guildId, withOpts())) + .flatMap(({ name, id, animated }) => + name && id + ? [{ name, identifier: `${name}:${id}`, ...(animated ? { animated } : {}) }] + : [], + ) + .toSorted( + (left, right) => + left.name.localeCompare(right.name) || left.identifier.localeCompare(right.identifier), + ) + .slice(0, limit); return jsonResult({ ok: true, emojis }); } case "emojiUpload": { diff --git a/extensions/discord/src/actions/runtime.test.ts b/extensions/discord/src/actions/runtime.test.ts index 45590b9c3909..a3414b7c9c86 100644 --- a/extensions/discord/src/actions/runtime.test.ts +++ b/extensions/discord/src/actions/runtime.test.ts @@ -1,11 +1,16 @@ import { expectDefined } from "@openclaw/normalization-core"; // Discord tests cover runtime plugin behavior. -import { ChannelType, PermissionFlagsBits } from "discord-api-types/v10"; +import { + ChannelType, + PermissionFlagsBits, + type RESTGetAPIGuildEmojisResult, +} from "discord-api-types/v10"; import type { ChannelMessageActionContext } from "openclaw/plugin-sdk/channel-contract"; import type { OpenClawConfig, DiscordActionConfig } from "openclaw/plugin-sdk/config-contracts"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { clearPresences, setPresence } from "../monitor/presence-cache.js"; import { DiscordThreadInitialMessageError } from "../send.js"; +import { handleDiscordMessageAction } from "./handle-action.js"; import { discordGuildActionRuntime, discordModerationActionRuntime } from "./runtime-deps.js"; import { handleDiscordGuildAction } from "./runtime.guild.js"; import { handleDiscordAction } from "./runtime.js"; @@ -67,7 +72,7 @@ const discordSendMocks = { fetchVoiceStatusDiscord: vi.fn(async () => ({})), kickMemberDiscord: vi.fn(async () => ({})), listGuildChannelsDiscord: vi.fn(async (): Promise => []), - listGuildEmojisDiscord: vi.fn(async () => []), + listGuildEmojisDiscord: vi.fn(async (): Promise => []), listPinsDiscord: vi.fn(async () => ({})), listScheduledEventsDiscord: vi.fn(async () => []), listThreadsDiscord: vi.fn(async () => ({})), @@ -2691,6 +2696,81 @@ describe("handleDiscordGuildAction", () => { expect(details.activities).toEqual([]); }); + it.each([ + { + label: "resolves the guild from the current channel", + params: {}, + expectedGuildId: "current-guild", + resolvesChannel: true, + }, + { + label: "prefers an explicit guild over the current channel", + params: { guildId: "explicit-guild" }, + expectedGuildId: "explicit-guild", + resolvesChannel: false, + }, + ])("$label for emoji-list", async ({ params, expectedGuildId, resolvesChannel }) => { + fetchChannelInfoDiscord.mockResolvedValueOnce({ + id: "123", + type: ChannelType.GuildText, + guild_id: "current-guild", + }); + + const result = await handleDiscordMessageAction({ + action: "emoji-list", + params, + cfg: DISCORD_TEST_CFG, + toolContext: { currentChannelProvider: "discord", currentChannelId: "channel:123" }, + }); + + expect(result.details).toEqual({ ok: true, emojis: [] }); + expect(listGuildEmojisDiscord).toHaveBeenCalledWith(expectedGuildId, { cfg: DISCORD_TEST_CFG }); + expect(fetchChannelInfoDiscord).toHaveBeenCalledTimes(resolvesChannel ? 1 : 0); + }); + + it("returns sorted, limited reaction-ready custom emoji without REST metadata", async () => { + listGuildEmojisDiscord.mockResolvedValueOnce([ + { id: "3", name: "zeta", animated: false, roles: ["role-1"] }, + { id: "1", name: "alpha", animated: true, managed: true }, + { id: "2", name: "beta", available: true }, + { id: null, name: "missing-id" }, + { id: "4", name: null }, + ]); + + const result = await handleGuildAction( + "emojiList", + { guildId: "G1", limit: 2 }, + enableAllActions, + ); + + expect(result.details).toEqual({ + ok: true, + emojis: [ + { name: "alpha", identifier: "alpha:1", animated: true }, + { name: "beta", identifier: "beta:2" }, + ], + }); + }); + + it("bounds emoji-list output even when a larger limit is requested", async () => { + listGuildEmojisDiscord.mockResolvedValueOnce( + Array.from({ length: 101 }, (_, index) => ({ + id: String(index + 1), + name: `emoji-${String(index).padStart(3, "0")}`, + })), + ); + + const result = await handleGuildAction( + "emojiList", + { guildId: "G1", limit: 500 }, + enableAllActions, + ); + + expect(result.details).toMatchObject({ ok: true, emojis: expect.any(Array) }); + const details = result.details as { emojis: unknown[] }; + expect(details.emojis).toHaveLength(100); + }); + it.each([ { action: "memberInfo", diff --git a/extensions/discord/src/channel-actions.test.ts b/extensions/discord/src/channel-actions.test.ts index 78de6d4aec91..0b7b587122c1 100644 --- a/extensions/discord/src/channel-actions.test.ts +++ b/extensions/discord/src/channel-actions.test.ts @@ -54,7 +54,6 @@ describe("discordMessageActions", () => { }); expect(discovery?.capabilities).toEqual(["presentation"]); - expect(discovery?.schema).toBeUndefined(); expect(discovery?.actions).toEqual([ "send", "poll", @@ -328,6 +327,13 @@ describe("discordMessageActions", () => { "event-list", "event-create", ]); + expect(defaultDiscovery?.schema).toBeUndefined(); + expect(workDiscovery?.schema).toMatchObject({ + actions: ["react", "reactions"], + properties: { + emoji: { description: expect.stringContaining('action:"emoji-list"') }, + }, + }); }); it("hides upload-file when Discord message actions are disabled", () => { @@ -351,7 +357,7 @@ describe("discordMessageActions", () => { expect(discovery?.actions).not.toContain("delete"); }); - it("does not expose Discord-native message tool schema", () => { + it("describes usable custom emoji formats and available server emoji discovery", () => { const discovery = discordMessageActions.describeMessageTool?.({ cfg: { channels: { @@ -361,7 +367,16 @@ describe("discordMessageActions", () => { }, } as OpenClawConfig, }); - expect(discovery?.schema).toBeUndefined(); + expect(discovery?.schema).toMatchObject({ + actions: ["react", "reactions"], + properties: { + emoji: { + description: expect.stringMatching( + /Unicode.*name:id.*<:name:id>.*.*emoji-list/, + ), + }, + }, + }); }); it.each(["read", "search", "edit", "delete", "react", "pin", "channel-info"])( diff --git a/extensions/discord/src/channel-actions.ts b/extensions/discord/src/channel-actions.ts index 2b048a103c4c..ddc3d55092ed 100644 --- a/extensions/discord/src/channel-actions.ts +++ b/extensions/discord/src/channel-actions.ts @@ -9,6 +9,7 @@ import type { DiscordActionConfig, OpenClawConfig } from "openclaw/plugin-sdk/co import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime"; import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; import { extractToolSend } from "openclaw/plugin-sdk/tool-send"; +import { Type } from "typebox"; import { inspectDiscordAccount } from "./account-inspect.js"; import { createDiscordActionGate, listDiscordAccountIds } from "./accounts.js"; import { readDiscordComponentSpec } from "./components.js"; @@ -172,6 +173,20 @@ function describeDiscordMessageTool({ return { actions: Array.from(actions), capabilities: ["presentation"], + ...(actions.has("react") + ? { + schema: { + properties: { + emoji: Type.Optional( + Type.String({ + description: `Unicode emoji or custom name:id (also <:name:id> / ).${actions.has("emoji-list") ? ' Use action:"emoji-list" for server emojis.' : ""}`, + }), + ), + }, + actions: ["react", "reactions"], + }, + } + : {}), }; } diff --git a/extensions/discord/src/internal/api.guild.ts b/extensions/discord/src/internal/api.guild.ts index 208ba4932dc1..0db5d3628f02 100644 --- a/extensions/discord/src/internal/api.guild.ts +++ b/extensions/discord/src/internal/api.guild.ts @@ -7,10 +7,24 @@ import { type APIGuildScheduledEvent, type APIRole, type APIVoiceState, + type RESTGetAPIGuildEmojisResult, type RESTPostAPIGuildScheduledEventJSONBody, } from "discord-api-types/v10"; +import { Type } from "typebox"; +import { Check } from "typebox/value"; import type { RequestClient, RequestData } from "./rest.js"; +const discordGuildEmojiListSchema = Type.Array( + Type.Object( + { + id: Type.Union([Type.String(), Type.Null()]), + name: Type.Union([Type.String(), Type.Null()]), + animated: Type.Optional(Type.Boolean()), + }, + { additionalProperties: true }, + ), +); + export async function getGuild(rest: RequestClient, guildId: string): Promise { return (await rest.get(Routes.guild(guildId))) as APIGuild; } @@ -144,8 +158,15 @@ export async function createGuildBan( await rest.put(Routes.guildBan(guildId, userId), data); } -export async function listGuildEmojis(rest: RequestClient, guildId: string): Promise { - return await rest.get(Routes.guildEmojis(guildId)); +export async function listGuildEmojis( + rest: RequestClient, + guildId: string, +): Promise { + const emojis = await rest.get(Routes.guildEmojis(guildId)); + if (!Check(discordGuildEmojiListSchema, emojis)) { + throw new Error("Invalid Discord guild emoji response."); + } + return emojis; } export async function createGuildEmoji( diff --git a/extensions/discord/src/internal/api.test.ts b/extensions/discord/src/internal/api.test.ts index 1a1e1b75ca36..dc49ddc3da4b 100644 --- a/extensions/discord/src/internal/api.test.ts +++ b/extensions/discord/src/internal/api.test.ts @@ -25,6 +25,7 @@ import { listApplicationCommands, listChannelMessages, listGuildChannels, + listGuildEmojis, overwriteApplicationCommands, pinChannelMessage, searchGuildMessages, @@ -77,7 +78,12 @@ describe("Discord REST API helpers", () => { }); it("routes guild helpers through the typed REST client", async () => { - const rest = createFakeRestClient([[{ id: "c1" }], { id: "event1" }, undefined]); + const rest = createFakeRestClient([ + [{ id: "c1" }], + [{ id: "emoji1", name: "party", animated: true }], + { id: "event1" }, + undefined, + ]); const body = { name: "standup", scheduled_start_time: "2026-04-29T10:00:00.000Z", @@ -87,11 +93,15 @@ describe("Discord REST API helpers", () => { } as const; await expect(listGuildChannels(rest, "g1")).resolves.toEqual([{ id: "c1" }]); + await expect(listGuildEmojis(rest, "g1")).resolves.toEqual([ + { id: "emoji1", name: "party", animated: true }, + ]); await expect(createGuildScheduledEvent(rest, "g1", body)).resolves.toEqual({ id: "event1" }); await createGuildBan(rest, "g1", "u1", { body: { delete_message_seconds: 0 } }); expect(rest.calls).toEqual([ { method: "GET", path: Routes.guildChannels("g1") }, + { method: "GET", path: Routes.guildEmojis("g1") }, { method: "POST", path: Routes.guildScheduledEvents("g1"), @@ -105,6 +115,12 @@ describe("Discord REST API helpers", () => { ]); }); + it("rejects malformed guild emoji responses at the Discord REST boundary", async () => { + await expect(listGuildEmojis(createFakeRestClient([{ invalid: true }]), "g1")).rejects.toThrow( + "Invalid Discord guild emoji response.", + ); + }); + it("routes command helpers through the typed REST client", async () => { const rest = createFakeRestClient([ [{ id: "cmd1" }], diff --git a/extensions/slack/src/action-runtime.test.ts b/extensions/slack/src/action-runtime.test.ts index 03f5d654b45e..415618bab014 100644 --- a/extensions/slack/src/action-runtime.test.ts +++ b/extensions/slack/src/action-runtime.test.ts @@ -2217,20 +2217,26 @@ describe("handleSlackAction", () => { expect(sendSlackMessage).not.toHaveBeenCalled(); }); - it("returns all emojis when no limit is provided", async () => { + it("returns sorted usable emoji identifiers and preserves alias targets", async () => { listSlackEmojis.mockResolvedValueOnce({ ok: true, - emoji: { party: "https://example.com/party.png", wave: "https://example.com/wave.png" }, + cache_ts: "ignored-provider-metadata", + emoji: { + wave: "https://example.com/wave.png", + celebrate: "alias:party", + party: "https://example.com/party.png", + }, }); const result = await handleSlackAction({ action: "emojiList" }, slackConfig()); const details = requireDetails(result); expect(details.ok).toBe(true); - expect(details.emojis).toEqual({ - ok: true, - emoji: { party: "https://example.com/party.png", wave: "https://example.com/wave.png" }, - }); + expect(details.emojis).toEqual([ + { name: "celebrate", identifier: "celebrate", aliasOf: "party" }, + { name: "party", identifier: "party" }, + { name: "wave", identifier: "wave" }, + ]); }); it("applies limit to emoji-list results", async () => { @@ -2247,13 +2253,31 @@ describe("handleSlackAction", () => { const details = requireDetails(result); expect(details.ok).toBe(true); - expect(details.emojis).toEqual({ + expect(details.emojis).toEqual([ + { name: "party", identifier: "party" }, + { name: "tada", identifier: "tada" }, + ]); + }); + + it.each([undefined, 150])("bounds emoji-list output for limit %s", async (limit) => { + listSlackEmojis.mockResolvedValueOnce({ ok: true, - emoji: { - party: "https://example.com/party.png", - tada: "https://example.com/tada.png", - }, + emoji: Object.fromEntries( + Array.from({ length: 101 }, (_, index) => [ + `emoji${String(index).padStart(3, "0")}`, + "https://example.com/emoji.png", + ]), + ), }); + + const result = await handleSlackAction( + { action: "emojiList", ...(limit === undefined ? {} : { limit }) }, + slackConfig(), + ); + + const emojis = requireArray(requireDetails(result).emojis, "emoji list"); + expect(emojis).toHaveLength(100); + expect(emojis.at(-1)).toEqual({ name: "emoji099", identifier: "emoji099" }); }); it("rejects fractional emoji-list limits before reading emojis", async () => { diff --git a/extensions/slack/src/action-runtime.ts b/extensions/slack/src/action-runtime.ts index aa0fc3c6eb2c..37a5b2d5198f 100644 --- a/extensions/slack/src/action-runtime.ts +++ b/extensions/slack/src/action-runtime.ts @@ -50,7 +50,7 @@ const messagingActions = new Set([ const reactionsActions = new Set(["react", "reactions"]); const pinActions = new Set(["pinMessage", "unpinMessage", "listPins"]); -const SLACK_REACTION_USER_LIMIT = 100; +const SLACK_REACTION_RESULT_LIMIT = 100; type SlackActionsRuntimeModule = typeof import("./actions.runtime.js"); @@ -615,8 +615,8 @@ export async function handleSlackAction( const limit = Math.min( readPositiveIntegerParam(params, "limit", { message: "limit must be a positive integer.", - }) ?? SLACK_REACTION_USER_LIMIT, - SLACK_REACTION_USER_LIMIT, + }) ?? SLACK_REACTION_RESULT_LIMIT, + SLACK_REACTION_RESULT_LIMIT, ); const reactions = await slackActionRuntime.listSlackReactions(channelId, messageId, readOpts); return jsonResult({ @@ -1016,25 +1016,24 @@ export async function handleSlackAction( if (!isActionEnabled("emojiList")) { throw new Error("Slack emoji list is disabled."); } - const limit = readPositiveIntegerParam(params, "limit", { - message: "limit must be a positive integer.", - }); + const limit = Math.min( + readPositiveIntegerParam(params, "limit", { + message: "limit must be a positive integer.", + }) ?? SLACK_REACTION_RESULT_LIMIT, + SLACK_REACTION_RESULT_LIMIT, + ); const teamId = resolveTrustedCurrentSlackTeamId({ account, context }); assertSlackDetachedTargetAllowed(account.accountId, teamId); const result = await slackActionRuntime.listSlackEmojis(buildActionOpts("read", teamId)); - if (limit != null && limit > 0 && result.emoji != null) { - const entries = Object.entries(result.emoji).toSorted(([a], [b]) => a.localeCompare(b)); - if (entries.length > limit) { - return jsonResult({ - ok: true, - emojis: { - ...result, - emoji: Object.fromEntries(entries.slice(0, limit)), - }, - }); - } - } - return jsonResult({ ok: true, emojis: result }); + const emojis = Object.entries(result.emoji ?? {}) + .toSorted(([left], [right]) => left.localeCompare(right)) + .slice(0, limit) + .map(([name, value]) => + value.startsWith("alias:") + ? { name, identifier: name, aliasOf: value.slice("alias:".length) } + : { name, identifier: name }, + ); + return jsonResult({ ok: true, emojis }); } throw new Error(`Unknown action: ${action}`); diff --git a/extensions/slack/src/message-tool-api.ts b/extensions/slack/src/message-tool-api.ts index bb296fc30ad2..47c2ff0c3514 100644 --- a/extensions/slack/src/message-tool-api.ts +++ b/extensions/slack/src/message-tool-api.ts @@ -21,12 +21,16 @@ function createSlackFileActionSchema(): Record { }; } -function createSlackReactionEmojiSchema(): Record { +function createSlackReactionEmojiSchema(emojiListAvailable: boolean): Record { + const discoveryHint = emojiListAvailable + ? ' Discover workspace custom emoji with action:"emoji-list".' + : ""; return { emoji: Type.Optional( Type.String({ description: - 'Slack emoji shortcode name (for example "white_check_mark" or "+1") or common emoji character (for example "✅"). Colons are optional around shortcodes.', + 'Slack standard or workspace custom emoji shortcode (for example "white_check_mark" or "+1") or common emoji character (for example "✅"). Colons are optional.' + + discoveryHint, }), ), }; @@ -114,7 +118,7 @@ export function describeSlackMessageTool({ } if (actions.includes("react")) { schema.push({ - properties: createSlackReactionEmojiSchema(), + properties: createSlackReactionEmojiSchema(actions.includes("emoji-list")), actions: ["react", "reactions"], }); } diff --git a/extensions/slack/src/message-tools.test.ts b/extensions/slack/src/message-tools.test.ts index eccffca34940..78bc44e9197c 100644 --- a/extensions/slack/src/message-tools.test.ts +++ b/extensions/slack/src/message-tools.test.ts @@ -407,23 +407,30 @@ describe("Slack message tools", () => { expect(alias.description).toMatch(/Alias for messageId/i); }); - it("describes Slack shortcode and common glyph reaction inputs", () => { - const discovery = describeSlackMessageTool({ - cfg: { - channels: { - slack: { - botToken: "xoxb-test", + it.each([true, false])( + "describes Slack custom emoji and advertises discovery only when enabled (%s)", + (emojiList) => { + const discovery = describeSlackMessageTool({ + cfg: { + channels: { + slack: { + botToken: "xoxb-test", + actions: { emojiList }, + }, }, }, - }, - }); + }); - const { schema, property } = requireSchemaProperty(discovery, "emoji"); + const { schema, property } = requireSchemaProperty(discovery, "emoji"); - expect(schema.actions).toEqual(["react", "reactions"]); - expect(property.description).toContain("white_check_mark"); - expect(property.description).toContain("✅"); - }); + expect(schema.actions).toEqual(["react", "reactions"]); + expect(property.description).toContain("white_check_mark"); + expect(property.description).toContain("✅"); + expect(property.description).toContain("workspace custom emoji"); + expect(property.description?.includes('action:"emoji-list"')).toBe(emojiList); + expect(discovery.actions?.includes("emoji-list")).toBe(emojiList); + }, + ); it("omits the react emoji schema when reactions are disabled", () => { const discovery = describeSlackMessageTool({ diff --git a/extensions/telegram/src/action-runtime.test.ts b/extensions/telegram/src/action-runtime.test.ts index 78a3591c8511..c8efc2644905 100644 --- a/extensions/telegram/src/action-runtime.test.ts +++ b/extensions/telegram/src/action-runtime.test.ts @@ -34,6 +34,9 @@ function handleTelegramAction( }); } const reactMessageTelegram = vi.fn(async () => ({ ok: true })); +const getTelegramAllowedReactions = vi.fn( + async () => null, +); const sendMessageTelegram = vi.fn( async (_to: string, _text: string, _opts?: Record) => ({ messageId: "789", @@ -354,6 +357,7 @@ describe("handleTelegramAction", () => { installTopicNameStoreForTest(); Object.assign(telegramActionRuntime, originalTelegramActionRuntime, { reactMessageTelegram, + getTelegramAllowedReactions, sendDurableMessageBatch, sendMessageTelegram, sendPollTelegram, @@ -366,6 +370,7 @@ describe("handleTelegramAction", () => { createForumTopicTelegram, }); reactMessageTelegram.mockClear(); + getTelegramAllowedReactions.mockReset().mockResolvedValue(null); sendDurableMessageBatch.mockClear(); sendMessageTelegram.mockClear(); sendPollTelegram.mockClear(); @@ -602,6 +607,11 @@ describe("handleTelegramAction", () => { ok: false, warning: "Reaction unavailable: ✅", } as unknown as Awaited>); + getTelegramAllowedReactions.mockResolvedValueOnce([ + { type: "emoji", emoji: "👍" }, + { type: "custom_emoji", custom_emoji_id: "5231419410191111111" }, + { type: "emoji", emoji: "🔥" }, + ]); const result = await handleTelegramAction(defaultReactionAction, reactionConfig("minimal")); const textPayload = result.content.find((item) => item.type === "text"); expect(textPayload?.type).toBe("text"); @@ -611,10 +621,153 @@ describe("handleTelegramAction", () => { added?: string; }; expect(parsed.ok).toBe(false); - expect(parsed.warning).toBe("Reaction unavailable: ✅"); + expect(parsed.warning).toBe("Reaction unavailable: ✅ This chat allows: 👍 🔥."); + expect(parsed.warning).not.toContain("disallow list"); expect(parsed.added).toBe("✅"); }); + it("bounds allowed-reaction guidance when Telegram rejects a reaction", async () => { + reactMessageTelegram.mockRejectedValueOnce(new Error("400: REACTION_INVALID")); + getTelegramAllowedReactions.mockResolvedValueOnce( + Array.from({ length: 25 }, () => ({ type: "emoji" as const, emoji: "👍" as const })), + ); + + const details = resultDetails( + await handleTelegramAction(defaultReactionAction, reactionConfig("minimal")), + ); + + expect(details).toMatchObject({ + ok: false, + reason: "REACTION_INVALID", + hint: expect.stringContaining("This chat allows:"), + }); + expect(String(details.hint).match(/👍/gu)).toHaveLength(20); + expect(details.hint).not.toContain("disallow list"); + }); + + it("lists permitted standard and custom reactions with an optional limit", async () => { + getTelegramAllowedReactions.mockResolvedValueOnce([ + { type: "emoji", emoji: "👍" }, + { type: "custom_emoji", custom_emoji_id: "5231419410191111111" }, + { type: "emoji", emoji: "🔥" }, + ]); + + const details = resultDetails( + await handleTelegramAction( + { action: "emoji-list", chatId: "-1001", limit: 2 }, + telegramConfig(), + ), + ); + + expect(details).toEqual({ + ok: true, + emojis: [ + { name: "👍", identifier: "👍" }, + { identifier: "5231419410191111111", type: "custom_emoji" }, + ], + }); + expect(getTelegramAllowedReactions).toHaveBeenCalledWith( + "-1001", + expect.objectContaining({ token: "tok" }), + ); + }); + + it("returns bounded standard reactions when Telegram reports no chat restriction", async () => { + const details = resultDetails( + await handleTelegramAction({ action: "emoji-list", chatId: "-1001" }, telegramConfig()), + ); + + expect(details.note).toBe("All standard Telegram reactions are allowed."); + expect(details.emojis).toEqual(expect.arrayContaining([{ name: "👍", identifier: "👍" }])); + expect((details.emojis as unknown[]).length).toBeLessThanOrEqual(100); + }); + + it("caps explicitly requested reaction-list limits at 100", async () => { + getTelegramAllowedReactions.mockResolvedValueOnce( + Array.from({ length: 120 }, (_, index) => ({ + type: "custom_emoji" as const, + custom_emoji_id: String(index), + })), + ); + + const details = resultDetails( + await handleTelegramAction( + { action: "emoji-list", chatId: "-1001", limit: 200 }, + telegramConfig(), + ), + ); + + expect(details.emojis).toHaveLength(100); + }); + + it("defaults delegated reaction discovery to the trusted current Telegram chat", async () => { + await handleTelegramAction({ action: "emoji-list" }, telegramConfig(), { + conversationReadOrigin: "delegated", + requesterAccountId: "default", + toolContext: { + currentChannelProvider: "telegram", + currentChannelId: "telegram:-1001:topic:77", + currentThreadTs: "77", + }, + }); + + expect(getTelegramAllowedReactions).toHaveBeenCalledWith("-1001", expect.anything()); + }); + + it.each([ + { + name: "a different chat", + chatId: "-1002", + requesterAccountId: "default", + currentChannelProvider: "telegram", + }, + { + name: "a different account", + chatId: "-1001", + requesterAccountId: "other", + currentChannelProvider: "telegram", + }, + { + name: "a different provider", + chatId: "-1001", + requesterAccountId: "default", + currentChannelProvider: "discord", + }, + ])("rejects delegated reaction discovery for $name", async (testCase) => { + await expect( + handleTelegramAction({ action: "emoji-list", chatId: testCase.chatId }, telegramConfig(), { + conversationReadOrigin: "delegated", + requesterAccountId: testCase.requesterAccountId, + toolContext: { + currentChannelProvider: testCase.currentChannelProvider, + currentChannelId: "telegram:-1001", + }, + }), + ).rejects.toThrow("exact current chat and account"); + expect(getTelegramAllowedReactions).not.toHaveBeenCalled(); + }); + + it("allows direct operators to inspect an explicitly selected sibling chat", async () => { + await handleTelegramAction({ action: "emoji-list", chatId: "-1002" }, telegramConfig(), { + toolContext: { + currentChannelProvider: "telegram", + currentChannelId: "telegram:-1001", + }, + }); + + expect(getTelegramAllowedReactions).toHaveBeenCalledWith("-1002", expect.anything()); + }); + + it("rejects reaction discovery when the existing reactions action gate is disabled", async () => { + await expect( + handleTelegramAction( + { action: "emoji-list", chatId: "-1001" }, + telegramConfig({ actions: { reactions: false } }), + ), + ).rejects.toThrow("actions.reactions"); + expect(getTelegramAllowedReactions).not.toHaveBeenCalled(); + }); + it("adds reactions when reactionLevel is extensive", async () => { await expectReactionAdded("extensive"); }); diff --git a/extensions/telegram/src/action-runtime.ts b/extensions/telegram/src/action-runtime.ts index eee7a5cd5690..8ee195bac4c1 100644 --- a/extensions/telegram/src/action-runtime.ts +++ b/extensions/telegram/src/action-runtime.ts @@ -46,6 +46,7 @@ import { } from "./inline-buttons.js"; import { resolveTelegramInteractiveTextFallback } from "./interactive-fallback.js"; import { + resolveTelegramConversationReadChatId, resolveTelegramMessageMutationChatId, type TelegramMessageMutationContext, } from "./message-topic-binding.js"; @@ -58,12 +59,14 @@ import { editForumTopicTelegram, editMessageReplyMarkupTelegram, editMessageTelegram, + getTelegramAllowedReactions, pinMessageTelegram, reactMessageTelegram, sendMessageTelegram, sendPollTelegram, sendStickerTelegram, } from "./send.js"; +import { TELEGRAM_SUPPORTED_REACTION_EMOJI_LIST } from "./status-reaction-variants.js"; import { getCacheStats, searchStickers } from "./sticker-cache.js"; import { normalizeTelegramOutboundTarget, parseTelegramTarget } from "./targets.js"; import { resolveTelegramToken } from "./token.js"; @@ -75,6 +78,7 @@ export const telegramActionRuntime = { editForumTopicTelegram, editMessageReplyMarkupTelegram, editMessageTelegram, + getTelegramAllowedReactions, getCacheStats, pinMessageTelegram, reactMessageTelegram, @@ -88,6 +92,8 @@ export const telegramActionRuntime = { const TELEGRAM_FORUM_TOPIC_ICON_COLORS = [ 0x6fb9f0, 0xffd67e, 0xcb86db, 0x8eee98, 0xff93b2, 0xfb6f5f, ] as const; +const TELEGRAM_EMOJI_LIST_LIMIT = 100; +const TELEGRAM_REACTION_HINT_LIMIT = 20; const TELEGRAM_ACTION_ALIASES = { createForumTopic: "createForumTopic", delete: "deleteMessage", @@ -95,6 +101,7 @@ const TELEGRAM_ACTION_ALIASES = { edit: "editMessage", editForumTopic: "editForumTopic", editMessage: "editMessage", + "emoji-list": "emoji-list", poll: "poll", react: "react", searchSticker: "searchSticker", @@ -383,6 +390,26 @@ function getLastDurableTelegramActionResult( }; } +async function describeTelegramAllowedReactionSample(params: { + chatId: string | number; + cfg: OpenClawConfig; + token: string; + accountId?: string; +}): Promise { + const reactions = await telegramActionRuntime + .getTelegramAllowedReactions(params.chatId, { + cfg: params.cfg, + token: params.token, + accountId: params.accountId, + }) + .catch(() => null); + const emojis = reactions + ?.filter((reaction) => reaction.type === "emoji") + .slice(0, TELEGRAM_REACTION_HINT_LIMIT) + .map((reaction) => reaction.emoji); + return emojis?.length ? ` This chat allows: ${emojis.join(" ")}.` : ""; +} + export async function handleTelegramAction( params: Record, cfg: OpenClawConfig, @@ -418,6 +445,52 @@ export async function handleTelegramAction( }); }; + if (action === "emoji-list") { + if (!isActionEnabled("reactions")) { + throw new Error("Telegram reactions are disabled via actions.reactions."); + } + const chatId = resolveTelegramConversationReadChatId({ + chatId: + readStringOrNumberParam(params, "chatId") ?? + readStringOrNumberParam(params, "channelId") ?? + readStringOrNumberParam(params, "to"), + cfg, + accountId, + context: options, + }); + const token = resolveTelegramToken(cfg, { accountId }).token; + if (!token) { + throw new Error( + "Telegram bot token missing. Set TELEGRAM_BOT_TOKEN or channels.telegram.botToken.", + ); + } + const limit = Math.min( + readPositiveIntegerParam(params, "limit", { + message: "limit must be a positive integer.", + }) ?? TELEGRAM_EMOJI_LIST_LIMIT, + TELEGRAM_EMOJI_LIST_LIMIT, + ); + const allowed = await telegramActionRuntime.getTelegramAllowedReactions(chatId, { + cfg, + token, + accountId: accountId ?? undefined, + }); + const reactions = + allowed ?? + TELEGRAM_SUPPORTED_REACTION_EMOJI_LIST.map((emoji) => ({ type: "emoji" as const, emoji })); + return jsonResult({ + ok: true, + emojis: reactions + .slice(0, limit) + .map((reaction) => + reaction.type === "emoji" + ? { name: reaction.emoji, identifier: reaction.emoji } + : { identifier: reaction.custom_emoji_id, type: "custom_emoji" }, + ), + ...(allowed === null ? { note: "All standard Telegram reactions are allowed." } : {}), + }); + } + if (action === "react") { // All react failures return soft results (jsonResult with ok:false) instead // of throwing, because hard tool errors can trigger model re-generation @@ -473,8 +546,9 @@ export async function handleTelegramAction( }); } let reactionResult: Awaited>; + let authorizedChatId: string | number = chatId ?? ""; try { - const authorizedChatId = await resolveTelegramMessageMutationChatId({ + authorizedChatId = await resolveTelegramMessageMutationChatId({ chatId: chatId ?? "", messageId, cfg, @@ -500,14 +574,25 @@ export async function handleTelegramAction( reason: isInvalid ? "REACTION_INVALID" : "error", emoji, hint: isInvalid - ? "This emoji is not supported for Telegram reactions. Add it to your reaction disallow list so you do not try it again." + ? `This reaction is unavailable.${await describeTelegramAllowedReactionSample({ + chatId: authorizedChatId, + cfg, + token, + accountId: accountId ?? undefined, + })}` : "Reaction failed. Do not retry.", }); } if (!reactionResult.ok) { + const allowedHint = await describeTelegramAllowedReactionSample({ + chatId: authorizedChatId, + cfg, + token, + accountId: accountId ?? undefined, + }); return jsonResult({ ok: false, - warning: reactionResult.warning, + warning: `${reactionResult.warning}${allowedHint}`, ...(remove || isEmpty ? { removed: true } : { added: emoji }), }); } diff --git a/extensions/telegram/src/bot-message-context.reactions.test.ts b/extensions/telegram/src/bot-message-context.reactions.test.ts index 481312cdef1a..31448dd8356e 100644 --- a/extensions/telegram/src/bot-message-context.reactions.test.ts +++ b/extensions/telegram/src/bot-message-context.reactions.test.ts @@ -225,6 +225,7 @@ describe("buildTelegramMessageContext reactions", () => { type: "private", available_reactions: [ { type: "emoji", emoji: "👍" }, + { type: "custom_emoji", custom_emoji_id: "5231419410191111111" }, { type: "emoji", emoji: "❤" }, ], }, diff --git a/extensions/telegram/src/bot-message-context.ts b/extensions/telegram/src/bot-message-context.ts index 716ef930b41e..d603d38a2556 100644 --- a/extensions/telegram/src/bot-message-context.ts +++ b/extensions/telegram/src/bot-message-context.ts @@ -54,7 +54,7 @@ import { evaluateTelegramGroupBaseAccess } from "./group-access.js"; import { buildTelegramStatusReactionVariants, type TelegramReactionEmoji, - resolveTelegramAllowedEmojiReactions, + resolveTelegramAllowedReactions, resolveTelegramReactionEmoji, resolveTelegramReactionVariant, resolveTelegramStatusReactionEmojis, @@ -595,16 +595,26 @@ export const buildTelegramMessageContext = async ({ setReaction: async (emoji: string) => { if (reactionApi) { if (!allowedStatusReactionEmojisPromise) { - allowedStatusReactionEmojisPromise = resolveTelegramAllowedEmojiReactions({ + allowedStatusReactionEmojisPromise = resolveTelegramAllowedReactions({ chat: msg.chat, chatId, getChat: getChatApi ?? undefined, - }).catch((err: unknown) => { - logVerbose( - `telegram status-reaction available_reactions lookup failed for chat ${chatId}: ${String(err)}`, - ); - return null; - }); + }) + .then((reactions) => + reactions + ? new Set( + reactions.flatMap((reaction) => + reaction.type === "emoji" ? [reaction.emoji] : [], + ), + ) + : null, + ) + .catch((err: unknown) => { + logVerbose( + `telegram status-reaction available_reactions lookup failed for chat ${chatId}: ${String(err)}`, + ); + return null; + }); } const allowedStatusReactionEmojis = await allowedStatusReactionEmojisPromise; const resolvedEmoji = resolveTelegramReactionVariant({ diff --git a/extensions/telegram/src/channel-actions.contract.test.ts b/extensions/telegram/src/channel-actions.contract.test.ts index d7a75f35de50..952ddf5cdc6a 100644 --- a/extensions/telegram/src/channel-actions.contract.test.ts +++ b/extensions/telegram/src/channel-actions.contract.test.ts @@ -17,14 +17,28 @@ describe("telegram actions contract", () => { }, }, } as OpenClawConfig, - expectedActions: ["send", "poll", "react", "delete", "edit", "topic-create", "topic-edit"], + expectedActions: [ + "send", + "poll", + "react", + "emoji-list", + "delete", + "edit", + "topic-create", + "topic-edit", + ], expectedCapabilities: ["delivery-pin", "presentation"], }, ], }); it("exposes provider-owned read gates and message resource aliases through the registered adapter", () => { - expect(telegramPlugin.actions?.providerOwnedReadGates).toEqual(["react", "edit", "delete"]); + expect(telegramPlugin.actions?.providerOwnedReadGates).toEqual([ + "react", + "edit", + "delete", + "emoji-list", + ]); for (const action of ["react", "edit", "delete"] as const) { expect(telegramPlugin.actions?.messageActionTargetAliases?.[action]).toEqual({ aliases: ["messageId"], diff --git a/extensions/telegram/src/channel-actions.test.ts b/extensions/telegram/src/channel-actions.test.ts index 006011a8233a..f639aa70083f 100644 --- a/extensions/telegram/src/channel-actions.test.ts +++ b/extensions/telegram/src/channel-actions.test.ts @@ -29,7 +29,7 @@ describe("telegramMessageActions", () => { for (const action of ["sendMessage", "editMessage", "deleteMessage", "react", "topic-edit"]) { expect(telegramMessageActions.isToolDeliveryAction?.({ args: { action } })).toBe(true); } - for (const action of ["searchSticker", "stickerCacheStats"]) { + for (const action of ["searchSticker", "stickerCacheStats", "emoji-list"]) { expect(telegramMessageActions.isToolDeliveryAction?.({ args: { action } })).toBe(false); } }); @@ -338,8 +338,10 @@ describe("telegramMessageActions", () => { expect(defaultActions).toContain("send"); expect(defaultActions).toContain("poll"); expect(defaultActions).not.toContain("react"); + expect(defaultActions).not.toContain("emoji-list"); expect(workActions).not.toContain("send"); expect(workActions).toContain("react"); + expect(workActions).toContain("emoji-list"); expect(workActions).not.toContain("poll"); }); diff --git a/extensions/telegram/src/channel-actions.ts b/extensions/telegram/src/channel-actions.ts index 4467697761e4..0a79afcbd988 100644 --- a/extensions/telegram/src/channel-actions.ts +++ b/extensions/telegram/src/channel-actions.ts @@ -42,6 +42,7 @@ const telegramMessageActionRuntime = { const TELEGRAM_MESSAGE_ACTION_MAP = { delete: "deleteMessage", edit: "editMessage", + "emoji-list": "emoji-list", poll: "poll", react: "react", send: "sendMessage", @@ -178,6 +179,7 @@ function describeTelegramMessageTool({ } if (discovery.isEnabled("reactions")) { actions.add("react"); + actions.add("emoji-list"); } if (discovery.isEnabled("deleteMessage")) { actions.add("delete"); @@ -217,7 +219,7 @@ function describeTelegramMessageTool({ export const telegramMessageActions: ChannelMessageActionAdapter = { describeMessageTool: describeTelegramMessageTool, - providerOwnedReadGates: ["react", "edit", "delete"], + providerOwnedReadGates: ["react", "edit", "delete", "emoji-list"], resolveExecutionMode: () => "gateway", messageActionTargetAliases: { react: { aliases: ["messageId"], deliveryTargetAliases: [] }, diff --git a/extensions/telegram/src/message-topic-binding.ts b/extensions/telegram/src/message-topic-binding.ts index 2872d4893f99..cc8191ae361e 100644 --- a/extensions/telegram/src/message-topic-binding.ts +++ b/extensions/telegram/src/message-topic-binding.ts @@ -28,6 +28,8 @@ export type TelegramMessageMutationContext = { const TOPIC_BINDING_ERROR = "Delegated Telegram message mutation requires a provider-observed binding to the exact current topic and account."; +const CONVERSATION_BINDING_ERROR = + "Delegated Telegram conversation read requires the exact current chat and account."; function rejectUnboundTopicMutation(): never { throw new Error(TOPIC_BINDING_ERROR); @@ -66,6 +68,54 @@ function resolveCurrentTelegramConversation( }; } +function resolveMatchingTelegramRequesterAccount(params: { + cfg: OpenClawConfig; + accountId?: string | null; + context?: TelegramMessageMutationContext; +}): string | undefined { + const accountId = normalizeOptionalAccountId( + params.accountId ?? resolveDefaultTelegramAccountId(params.cfg), + ); + const requesterAccountId = normalizeOptionalAccountId(params.context?.requesterAccountId); + return accountId && + requesterAccountId && + normalizeAccountId(accountId) === normalizeAccountId(requesterAccountId) + ? accountId + : undefined; +} + +export function resolveTelegramConversationReadChatId(params: { + chatId?: string | number; + cfg: OpenClawConfig; + accountId?: string | null; + context?: TelegramMessageMutationContext; +}): string { + const currentTarget = + params.context?.toolContext?.currentChannelId ?? + params.context?.toolContext?.currentMessagingTarget; + const requestedTarget = params.chatId ?? currentTarget; + if (requestedTarget == null || !String(requestedTarget).trim()) { + throw new Error("Telegram emoji-list requires a chatId or current Telegram conversation."); + } + const target = parseTelegramTarget(String(requestedTarget)); + if (params.context?.conversationReadOrigin === "direct-operator") { + return target.chatId; + } + const currentConversation = resolveCurrentTelegramConversation( + params.context?.toolContext, + target.chatId, + ); + if ( + !resolveMatchingTelegramRequesterAccount(params) || + !currentConversation.matchesChat || + (target.messageThreadId !== undefined && + target.messageThreadId !== currentConversation.threadId) + ) { + throw new Error(CONVERSATION_BINDING_ERROR); + } + return target.chatId; +} + export async function resolveTelegramMessageMutationChatId(params: { chatId: string | number; messageId: number; @@ -82,16 +132,8 @@ export async function resolveTelegramMessageMutationChatId(params: { params.context?.toolContext, target.chatId, ); - const selectedAccountId = normalizeOptionalAccountId( - params.accountId ?? resolveDefaultTelegramAccountId(params.cfg), - ); - const requesterAccountId = normalizeOptionalAccountId(params.context?.requesterAccountId); - if ( - !selectedAccountId || - !requesterAccountId || - normalizeAccountId(selectedAccountId) !== normalizeAccountId(requesterAccountId) || - !currentConversation.matchesChat - ) { + const selectedAccountId = resolveMatchingTelegramRequesterAccount(params); + if (!selectedAccountId || !currentConversation.matchesChat) { return rejectUnboundTopicMutation(); } diff --git a/extensions/telegram/src/send-actions.ts b/extensions/telegram/src/send-actions.ts index a08d84dc168d..afee07af1f6c 100644 --- a/extensions/telegram/src/send-actions.ts +++ b/extensions/telegram/src/send-actions.ts @@ -18,7 +18,10 @@ import type { TelegramSendOpts, } from "./send-message-types.js"; import { prepareTelegramOutbound } from "./send-outbound.js"; -import { resolveTelegramReactionEmoji } from "./status-reaction-variants.js"; +import { + resolveTelegramAllowedReactions, + resolveTelegramReactionEmoji, +} from "./status-reaction-variants.js"; import { parseTelegramTarget, type TelegramTarget } from "./targets.js"; type TelegramReactionOpts = TelegramApiCallOpts & { @@ -28,6 +31,21 @@ type TelegramReactionOpts = TelegramApiCallOpts & { type TelegramTypingOpts = Omit & Pick; +export async function getTelegramAllowedReactions( + chatId: string | number, + opts: TelegramApiCallOpts, +): ReturnType { + const context = resolveTelegramApiContext(opts); + return withTelegramApiContextLease( + context, + resolveTelegramAllowedReactions({ + chat: undefined, + chatId, + getChat: (targetChatId) => context.api.getChat(targetChatId), + }), + ); +} + export async function sendTypingTelegram( to: string, opts: TelegramTypingOpts, @@ -113,8 +131,13 @@ async function reactMessageTelegramWithContext( // Unsupported emoji remain server-validated so existing graceful failures stay intact. const reactionEmoji = resolveTelegramReactionEmoji(trimmedEmoji) ?? (trimmedEmoji as ReactionTypeEmoji["emoji"]); + // Telegram custom emoji IDs are numeric; preserve the native reaction variant on the wire. const reactions: ReactionType[] = - remove || !trimmedEmoji ? [] : [{ type: "emoji", emoji: reactionEmoji }]; + remove || !trimmedEmoji + ? [] + : /^\d+$/.test(trimmedEmoji) + ? [{ type: "custom_emoji", custom_emoji_id: trimmedEmoji }] + : [{ type: "emoji", emoji: reactionEmoji }]; if (typeof api.setMessageReaction !== "function") { throw new Error("Telegram reactions are unavailable in this bot API."); } diff --git a/extensions/telegram/src/send-mutation-targets.test.ts b/extensions/telegram/src/send-mutation-targets.test.ts index fa01618a79ab..be66bdf5a000 100644 --- a/extensions/telegram/src/send-mutation-targets.test.ts +++ b/extensions/telegram/src/send-mutation-targets.test.ts @@ -110,6 +110,16 @@ describe("Telegram reaction presentation", () => { ]); }); + it("sends discovered numeric custom emoji identifiers as native custom reactions", async () => { + botApi.setMessageReaction.mockResolvedValue(true); + + await reactMessageTelegram(chatId, messageId, "5231419410191111111", opts); + + expect(botApi.setMessageReaction).toHaveBeenCalledWith(chatId, messageId, [ + { type: "custom_emoji", custom_emoji_id: "5231419410191111111" }, + ]); + }); + it("preserves reaction removal without sending an emoji", async () => { botApi.setMessageReaction.mockResolvedValue(true); diff --git a/extensions/telegram/src/send.ts b/extensions/telegram/src/send.ts index 884b09cb9085..4d321c3ec371 100644 --- a/extensions/telegram/src/send.ts +++ b/extensions/telegram/src/send.ts @@ -5,6 +5,7 @@ export { } from "./send-context.js"; export { deleteMessageTelegram, + getTelegramAllowedReactions, pinMessageTelegram, reactMessageTelegram, sendTypingTelegram, diff --git a/extensions/telegram/src/status-reaction-variants.ts b/extensions/telegram/src/status-reaction-variants.ts index 0b8d4e466d5a..f2a54031552e 100644 --- a/extensions/telegram/src/status-reaction-variants.ts +++ b/extensions/telegram/src/status-reaction-variants.ts @@ -1,5 +1,5 @@ // Telegram plugin module implements status reaction variants behavior. -import type { ReactionTypeEmoji } from "grammy/types"; +import type { ReactionTypeCustomEmoji, ReactionTypeEmoji } from "grammy/types"; import { DEFAULT_EMOJIS, type StatusReactionEmojis } from "openclaw/plugin-sdk/channel-feedback"; import { normalizeOptionalString, @@ -10,10 +10,11 @@ import type { TelegramChatDetails, TelegramGetChat } from "./bot/types.js"; type StatusReactionEmojiKey = keyof Required; export type TelegramReactionEmoji = ReactionTypeEmoji["emoji"]; +type TelegramAllowedReaction = ReactionTypeEmoji | ReactionTypeCustomEmoji; const TELEGRAM_GENERIC_REACTION_FALLBACKS = ["👍", "👀", "🔥"] as const; -const TELEGRAM_SUPPORTED_REACTION_EMOJI_LIST = [ +export const TELEGRAM_SUPPORTED_REACTION_EMOJI_LIST = [ "❤", "👍", "👎", @@ -173,9 +174,9 @@ export function resolveTelegramReactionEmoji(emoji: string): TelegramReactionEmo return TELEGRAM_SUPPORTED_REACTION_EMOJIS.get(emoji.trim().replace(/[\uFE0E\uFE0F]/gu, "")); } -function extractTelegramAllowedEmojiReactions( +function extractTelegramAllowedReactions( chat: TelegramChatDetails | null | undefined, -): Set | null | undefined { +): TelegramAllowedReaction[] | null | undefined { if (!chat) { return undefined; } @@ -188,41 +189,46 @@ function extractTelegramAllowedEmojiReactions( return null; } if (!Array.isArray(availableReactions)) { - return new Set(); + return []; } - const allowed = new Set(); + const allowed: TelegramAllowedReaction[] = []; + const identifiers = new Set(); for (const reaction of availableReactions) { + if (reaction.type === "custom_emoji") { + const identifier = normalizeOptionalString(reaction.custom_emoji_id); + if (identifier && !identifiers.has(`custom:${identifier}`)) { + identifiers.add(`custom:${identifier}`); + allowed.push({ type: "custom_emoji", custom_emoji_id: identifier }); + } + continue; + } if (reaction.type !== "emoji") { continue; } const emoji = resolveTelegramReactionEmoji(reaction.emoji); - if (emoji) { - allowed.add(emoji); + if (emoji && !identifiers.has(`emoji:${emoji}`)) { + identifiers.add(`emoji:${emoji}`); + allowed.push({ type: "emoji", emoji }); } } return allowed; } -export async function resolveTelegramAllowedEmojiReactions(params: { +export async function resolveTelegramAllowedReactions(params: { chat: TelegramChatDetails | null | undefined; chatId: string | number; getChat?: TelegramGetChat; -}): Promise | null> { - const fromMessage = extractTelegramAllowedEmojiReactions(params.chat); +}): Promise { + const fromMessage = extractTelegramAllowedReactions(params.chat); if (fromMessage !== undefined) { return fromMessage; } if (params.getChat) { - try { - const chatInfo = await params.getChat(params.chatId); - const fromLookup = extractTelegramAllowedEmojiReactions(chatInfo); - if (fromLookup !== undefined) { - return fromLookup; - } - } catch { - return null; + const fromLookup = extractTelegramAllowedReactions(await params.getChat(params.chatId)); + if (fromLookup !== undefined) { + return fromLookup; } } diff --git a/extensions/telegram/src/status.test.ts b/extensions/telegram/src/status.test.ts index b9e82e409774..eb77f209af33 100644 --- a/extensions/telegram/src/status.test.ts +++ b/extensions/telegram/src/status.test.ts @@ -6,7 +6,7 @@ import type { TelegramChatDetails, TelegramGetChat } from "./bot/types.js"; import { collectTelegramStatusIssues } from "./status-issues.js"; import { buildTelegramStatusReactionVariants, - resolveTelegramAllowedEmojiReactions, + resolveTelegramAllowedReactions, resolveTelegramReactionEmoji, resolveTelegramReactionVariant, resolveTelegramStatusReactionEmojis, @@ -346,9 +346,9 @@ describe("resolveTelegramReactionEmoji", () => { }); }); -describe("resolveTelegramAllowedEmojiReactions", () => { +describe("resolveTelegramAllowedReactions", () => { it("assumes no restriction when chat does not include available_reactions", async () => { - const result = await resolveTelegramAllowedEmojiReactions({ + const result = await resolveTelegramAllowedReactions({ chat: { id: 1 } satisfies TelegramChatDetails, chatId: 1, }); @@ -356,29 +356,34 @@ describe("resolveTelegramAllowedEmojiReactions", () => { }); it("returns null when available_reactions is omitted/null", async () => { - const result = await resolveTelegramAllowedEmojiReactions({ + const result = await resolveTelegramAllowedReactions({ chat: { available_reactions: null } satisfies TelegramChatDetails, chatId: 1, }); expect(result).toBeNull(); }); - it("extracts emoji reactions only", async () => { - const result = await resolveTelegramAllowedEmojiReactions({ + it("preserves standard and custom reactions while omitting paid reactions", async () => { + const result = await resolveTelegramAllowedReactions({ chat: { available_reactions: [ { type: "emoji", emoji: "👍" }, { type: "custom_emoji", custom_emoji_id: "abc" }, { type: "emoji", emoji: "🔥" }, + { type: "paid" }, ], } satisfies TelegramChatDetails, chatId: 1, }); - expect(result ? Array.from(result).toSorted() : null).toEqual(["👍", "🔥"]); + expect(result).toEqual([ + { type: "emoji", emoji: "👍" }, + { type: "custom_emoji", custom_emoji_id: "abc" }, + { type: "emoji", emoji: "🔥" }, + ]); }); - it("normalizes emoji presentation selectors without admitting custom reactions", async () => { - const result = await resolveTelegramAllowedEmojiReactions({ + it("normalizes emoji presentation selectors while retaining custom reactions", async () => { + const result = await resolveTelegramAllowedReactions({ chat: { available_reactions: [ { type: "emoji", emoji: "❤️" }, @@ -388,46 +393,47 @@ describe("resolveTelegramAllowedEmojiReactions", () => { chatId: 1, }); - expect(result).toEqual(new Set(["❤"])); + expect(result).toEqual([ + { type: "emoji", emoji: "❤" }, + { type: "custom_emoji", custom_emoji_id: "❤️" }, + ]); }); it("treats malformed available_reactions payloads as an empty allowlist instead of throwing", async () => { await expect( - resolveTelegramAllowedEmojiReactions({ + resolveTelegramAllowedReactions({ chat: { available_reactions: { type: "emoji", emoji: "👍" } } as never, chatId: 1, }), - ).resolves.toEqual(new Set()); + ).resolves.toEqual([]); }); -}); -describe("resolveTelegramAllowedEmojiReactions", () => { it("uses getChat lookup when message chat does not include available_reactions", async () => { const getChat: TelegramGetChat = async () => ({ available_reactions: [{ type: "emoji", emoji: "👍" }], }); - const result = await resolveTelegramAllowedEmojiReactions({ + const result = await resolveTelegramAllowedReactions({ chat: { id: 1 } satisfies TelegramChatDetails, chatId: 1, getChat, }); - expect(result ? Array.from(result) : null).toEqual(["👍"]); + expect(result).toEqual([{ type: "emoji", emoji: "👍" }]); }); - it("falls back to unrestricted reactions when getChat lookup fails", async () => { + it("surfaces getChat lookup failures so interactive discovery does not misreport restrictions", async () => { const getChat = async () => { throw new Error("lookup failed"); }; - const result = await resolveTelegramAllowedEmojiReactions({ - chat: { id: 1 } satisfies TelegramChatDetails, - chatId: 1, - getChat, - }); - - expect(result).toBeNull(); + await expect( + resolveTelegramAllowedReactions({ + chat: { id: 1 } satisfies TelegramChatDetails, + chatId: 1, + getChat, + }), + ).rejects.toThrow("lookup failed"); }); }); diff --git a/src/agents/tools/message-tool-schema.ts b/src/agents/tools/message-tool-schema.ts index dfd7881b5b38..468df9c627c5 100644 --- a/src/agents/tools/message-tool-schema.ts +++ b/src/agents/tools/message-tool-schema.ts @@ -242,7 +242,9 @@ function buildReactionSchema() { description: "snake_case alias of messageId; same defaults.", }), ), - emoji: Type.Optional(Type.String()), + emoji: Type.Optional( + Type.String({ description: "Unicode emoji; channels may also support custom emoji." }), + ), remove: Type.Optional(Type.Boolean()), trackToolCalls: Type.Optional( Type.Boolean({ @@ -262,7 +264,7 @@ function buildReactionSchema() { function buildFetchSchema() { return { - limit: optionalPositiveIntegerSchema(), + limit: optionalPositiveIntegerSchema({ description: "Maximum number of results to return." }), pageSize: optionalPositiveIntegerSchema(), pageToken: Type.Optional(Type.String()), before: Type.Optional(Type.String()), @@ -357,7 +359,7 @@ function buildChannelTargetSchema() { function buildStickerSchema() { return { fileId: Type.Optional(Type.String()), - emojiName: Type.Optional(Type.String()), + emojiName: Type.Optional(Type.String({ description: "Name for an uploaded custom emoji." })), stickerId: Type.Optional(Type.Array(Type.String())), stickerName: Type.Optional(Type.String()), stickerDesc: Type.Optional(Type.String()), diff --git a/src/agents/tools/message-tool.test.ts b/src/agents/tools/message-tool.test.ts index a612c193670e..2b245d081333 100644 --- a/src/agents/tools/message-tool.test.ts +++ b/src/agents/tools/message-tool.test.ts @@ -3695,12 +3695,26 @@ describe("message tool schema scoping", () => { it.each<{ action: ChannelMessageActionName; fields: string[]; + descriptions?: Record; }>([ + { + action: "react", + fields: ["messageId", "emoji"], + descriptions: { emoji: "Unicode emoji; channels may also support custom emoji." }, + }, { action: "search", fields: ["query", "limit"] }, { action: "reactions", fields: ["messageId", "limit"] }, { action: "sticker-search", fields: ["query", "limit"] }, - { action: "emoji-list", fields: ["guildId", "limit"] }, - { action: "emoji-upload", fields: ["guildId", "emojiName", "media", "roleIds"] }, + { + action: "emoji-list", + fields: ["guildId", "limit"], + descriptions: { limit: "Maximum number of results to return." }, + }, + { + action: "emoji-upload", + fields: ["guildId", "emojiName", "media", "roleIds"], + descriptions: { emojiName: "Name for an uploaded custom emoji." }, + }, { action: "sticker-upload", fields: ["guildId", "stickerName", "stickerDesc", "stickerTags", "media"], @@ -3713,7 +3727,7 @@ describe("message tool schema scoping", () => { { action: "setGroupIcon", fields: ["name", "filename", "buffer"] }, { action: "channel-info", fields: ["channelId", "pageSize", "pageToken"] }, { action: "channel-list", fields: ["query", "limit"] }, - ])("keeps fields consumed by scoped $action handlers", ({ action, fields }) => { + ])("keeps fields consumed by scoped $action handlers", ({ action, fields, descriptions }) => { const plugin = createChannelPlugin({ id: "test-channel", label: "Test Channel", @@ -3735,6 +3749,11 @@ describe("message tool schema scoping", () => { for (const field of fields) { expect(properties, `${action} should advertise ${field}`).toHaveProperty(field); } + for (const [field, description] of Object.entries(descriptions ?? {})) { + expect(properties[field], `${action} should describe ${field}`).toMatchObject({ + description, + }); + } }); it("describes the send payload contract on the action and message fields", () => { diff --git a/src/channels/plugins/contracts/plugin-shape.contract.test.ts b/src/channels/plugins/contracts/plugin-shape.contract.test.ts index 49eeddf7d18f..fc795047e897 100644 --- a/src/channels/plugins/contracts/plugin-shape.contract.test.ts +++ b/src/channels/plugins/contracts/plugin-shape.contract.test.ts @@ -52,7 +52,7 @@ const PROVIDER_OWNED_READ_GATE_PLUGINS = [ ["msteams", true], ["slack", true], ["mattermost", ["read"]], - ["telegram", ["react", "edit", "delete"]], + ["telegram", ["react", "edit", "delete", "emoji-list"]], ] as const; type ExplicitSessionKeyNormalizer = ( diff --git a/test/e2e/qa-lab/runtime/telegram-emoji-list-gateway.e2e.test.ts b/test/e2e/qa-lab/runtime/telegram-emoji-list-gateway.e2e.test.ts new file mode 100644 index 000000000000..9c8155bcf362 --- /dev/null +++ b/test/e2e/qa-lab/runtime/telegram-emoji-list-gateway.e2e.test.ts @@ -0,0 +1,363 @@ +import type { IncomingMessage, ServerResponse } from "node:http"; +import path from "node:path"; +import { withServer, withTempDir } from "openclaw/plugin-sdk/test-env"; +import { expect, test } from "vitest"; +import { + type MockOpenAiRequestSnapshot, + startQaGatewayChild, + startQaMockOpenAiServer, + writeJson, +} from "../../../../extensions/qa-lab/api.js"; + +type JsonObject = Record; +type TelegramCall = { pathname: string; method: string; body: JsonObject }; + +const BOT_TOKEN = `424242:${"A".repeat(35)}`; +const CURRENT_CHAT_ID = 2468; +const FOREIGN_CHAT_ID = 97531; +const CUSTOM_EMOJI_ID = "5368324170671202286"; +const CURRENT_CHAT_SCENARIO = "TELEGRAM_EMOJI_LIST_CURRENT_CHAT"; +const FOREIGN_CHAT_SCENARIO = "TELEGRAM_EMOJI_LIST_FOREIGN_CHAT"; +const CONVERSATION_BINDING_ERROR = + "Delegated Telegram conversation read requires the exact current chat and account."; + +async function readRequest(req: IncomingMessage): Promise { + let text = ""; + for await (const chunk of req) { + text += chunk; + } + return text; +} + +function succeed(res: ServerResponse, result: unknown = true) { + writeJson(res, 200, { ok: true, result }); +} + +function inboundUpdate(updateId: number, scenario: string) { + return { + update_id: updateId, + message: { + message_id: 9000 + updateId, + date: 1_754_000_000, + chat: { id: CURRENT_CHAT_ID, type: "private" }, + from: { id: CURRENT_CHAT_ID, is_bot: false, first_name: "QA" }, + text: `QA group visible reply tool check: ${scenario}`, + }, + }; +} + +function scriptMessageToolCall(payload: string, args: JsonObject) { + let scripted = false; + const argumentsText = JSON.stringify(args); + const finishAssistantMessage = (item: JsonObject | undefined) => { + if (item?.type !== "message" || !Array.isArray(item.content)) { + return; + } + for (const part of item.content as JsonObject[]) { + if (part.type === "output_text" && part.text === "") { + // The shared group-reply fixture ends empty; finish the turn so recovery never repeats discovery. + part.text = "Emoji discovery complete."; + } + } + }; + const scriptedPayload = payload + .split("\n") + .map((line) => { + if (!line.startsWith("data: ") || line === "data: [DONE]") { + return line; + } + const event = JSON.parse(line.slice(6)) as JsonObject; + if ( + event.type === "response.output_item.added" || + event.type === "response.output_item.done" + ) { + const item = event.item as JsonObject | undefined; + if (item?.name === "message") { + scripted = true; + if (item.arguments !== "") { + item.arguments = argumentsText; + } + } + finishAssistantMessage(item); + } else if (event.type === "response.function_call_arguments.delta" && scripted) { + event.delta = argumentsText; + } else if (event.type === "response.completed") { + const response = event.response as JsonObject | undefined; + const output = response?.output; + if (Array.isArray(output)) { + for (const item of output as JsonObject[]) { + if (item.name === "message") { + item.arguments = argumentsText; + scripted = true; + } + finishAssistantMessage(item); + } + } + } + return `data: ${JSON.stringify(event)}`; + }) + .join("\n"); + return { payload: scriptedPayload, scripted }; +} + +async function settleCleanup(...cleanups: Array<() => Promise>) { + const failures: unknown[] = []; + for (const cleanup of cleanups) { + await cleanup().catch((error: unknown) => failures.push(error)); + } + if (failures.length > 0) { + throw new AggregateError(failures, "Telegram emoji-list gateway cleanup failed"); + } +} + +test("binds Telegram emoji discovery to the current conversation before Bot API I/O", async () => { + const telegramCalls: TelegramCall[] = []; + const pendingUpdates: unknown[] = []; + const pendingPolls = new Set(); + const scriptedCalls: Array<{ scenario: string; arguments: JsonObject }> = []; + let mock: Awaited> | undefined; + + const queueUpdate = (update: unknown) => { + const poll = pendingPolls.values().next().value; + if (poll) { + pendingPolls.delete(poll); + succeed(poll, [update]); + return; + } + pendingUpdates.push(update); + }; + + const proxyProviderRequest = async ( + req: IncomingMessage, + res: ServerResponse, + pathname: string, + ) => { + if (!mock) { + writeJson(res, 503, { error: "mock provider is not ready" }); + return; + } + const raw = await readRequest(req); + const upstream = await fetch(`${mock.baseUrl}${pathname}`, { + method: req.method, + ...(raw ? { body: raw } : {}), + headers: { "content-type": "application/json" }, + }); + let payload = await upstream.text(); + const currentScenarioIndex = raw.lastIndexOf(CURRENT_CHAT_SCENARIO); + const foreignScenarioIndex = raw.lastIndexOf(FOREIGN_CHAT_SCENARIO); + const scenario = + foreignScenarioIndex > currentScenarioIndex + ? FOREIGN_CHAT_SCENARIO + : currentScenarioIndex >= 0 + ? CURRENT_CHAT_SCENARIO + : undefined; + + if (pathname === "/v1/responses" && scenario) { + const args = { + action: "emoji-list", + ...(scenario === FOREIGN_CHAT_SCENARIO ? { chatId: String(FOREIGN_CHAT_ID) } : {}), + }; + const scripted = scriptMessageToolCall(payload, args); + payload = scripted.payload; + if (scripted.scripted) { + scriptedCalls.push({ scenario, arguments: args }); + } + } + + res.writeHead(upstream.status, { + "content-type": upstream.headers.get("content-type") ?? "application/json", + }); + res.end(payload); + }; + + const handleRequest = async (req: IncomingMessage, res: ServerResponse) => { + const pathname = new URL(req.url ?? "/", "http://127.0.0.1").pathname; + if (pathname.startsWith("/v1/")) { + await proxyProviderRequest(req, res, pathname); + return; + } + + const [, token = "", method = ""] = pathname.match(/^\/bot([^/]+)\/([^/]+)$/) ?? []; + const raw = await readRequest(req); + const body = raw ? (JSON.parse(raw) as JsonObject) : {}; + telegramCalls.push({ pathname, method, body }); + + if (token !== BOT_TOKEN) { + writeJson(res, 401, { ok: false, error_code: 401, description: "Unexpected bot token" }); + return; + } + if (method === "getMe") { + succeed(res, { + id: 424242, + is_bot: true, + first_name: "QA Emoji", + username: "qa_emoji_bot", + }); + return; + } + if (method === "getUpdates") { + const update = pendingUpdates.shift(); + if (update) { + succeed(res, [update]); + } else { + pendingPolls.add(res); + req.on("close", () => pendingPolls.delete(res)); + } + return; + } + if (method === "getChat") { + succeed(res, { + id: Number(body.chat_id), + type: "private", + first_name: "QA", + available_reactions: [ + { type: "emoji", emoji: "👍" }, + { type: "custom_emoji", custom_emoji_id: CUSTOM_EMOJI_ID }, + ], + }); + return; + } + if (method === "sendMessage") { + succeed(res, { + message_id: 10_000 + telegramCalls.length, + date: 1_754_000_000, + chat: { id: Number(body.chat_id), type: "private" }, + text: body.text, + }); + return; + } + succeed(res); + }; + + await withServer( + (req, res) => { + void handleRequest(req, res).catch((error: unknown) => { + writeJson(res, 500, { + error: error instanceof Error ? error.message : String(error), + }); + }); + }, + async (apiRoot) => + await withTempDir("openclaw-telegram-emoji-list-", async (workspace) => { + let gateway: Awaited> | undefined; + try { + const repoRoot = path.resolve(import.meta.dirname, "../../../.."); + mock = await startQaMockOpenAiServer(); + gateway = await startQaGatewayChild({ + repoRoot, + useRepoCli: true, + providerBaseUrl: `${apiRoot}/v1`, + transportBaseUrl: apiRoot, + transport: { + requiredPluginIds: ["telegram"], + createGatewayConfig: () => ({ + channels: { + telegram: { + enabled: true, + defaultAccount: "proof", + accounts: { + proof: { + enabled: true, + botToken: BOT_TOKEN, + apiRoot, + dmPolicy: "open", + allowFrom: ["*"], + commands: { native: false }, + }, + }, + }, + }, + }), + }, + controlUiEnabled: false, + runtimeEnvPatch: { + OPENCLAW_SKIP_CHANNELS: undefined, + OPENCLAW_SKIP_PROVIDERS: undefined, + OPENCLAW_TEST_MINIMAL_GATEWAY: undefined, + TELEGRAM_BOT_TOKEN: undefined, + }, + mutateConfig: (cfg) => { + cfg.agents!.defaults!.workspace = workspace; + cfg.agents!.entries!.qa!.tools = { profile: "full" }; + cfg.tools = { ...cfg.tools, profile: "full", toolSearch: false, codeMode: false }; + cfg.bindings = [ + ...(cfg.bindings ?? []), + { agentId: "qa", match: { channel: "telegram", accountId: "proof" } }, + ]; + return cfg; + }, + }); + + const findToolResult = async (scenario: string) => { + const response = await fetch(`${mock!.baseUrl}/debug/requests`); + expect(response.ok).toBe(true); + const requests = (await response.json()) as MockOpenAiRequestSnapshot[]; + return requests.find( + (request) => request.prompt.includes(scenario) && request.toolOutput.length > 0, + ); + }; + + queueUpdate(inboundUpdate(1, CURRENT_CHAT_SCENARIO)); + await expect + .poll( + async () => ({ + request: await findToolResult(CURRENT_CHAT_SCENARIO), + scriptedCalls, + telegramMethods: telegramCalls.map((call) => call.method), + }), + { + interval: 50, + timeout: 30_000, + }, + ) + .toMatchObject({ + request: { toolOutput: expect.stringContaining(CUSTOM_EMOJI_ID) }, + scriptedCalls: [ + { scenario: CURRENT_CHAT_SCENARIO, arguments: { action: "emoji-list" } }, + ], + }); + + const currentChatResult = await findToolResult(CURRENT_CHAT_SCENARIO); + expect(JSON.parse(currentChatResult!.toolOutput)).toMatchObject({ + ok: true, + emojis: [ + { name: "👍", identifier: "👍" }, + { identifier: CUSTOM_EMOJI_ID, type: "custom_emoji" }, + ], + }); + expect( + telegramCalls.filter( + (call) => + call.method === "getChat" && String(call.body.chat_id) === String(CURRENT_CHAT_ID), + ), + ).toHaveLength(1); + + queueUpdate(inboundUpdate(2, FOREIGN_CHAT_SCENARIO)); + await expect + .poll(() => findToolResult(FOREIGN_CHAT_SCENARIO), { + interval: 50, + timeout: 30_000, + }) + .toMatchObject({ toolOutput: expect.stringContaining(CONVERSATION_BINDING_ERROR) }); + + const foreignChatResult = await findToolResult(FOREIGN_CHAT_SCENARIO); + expect(foreignChatResult!.toolOutput).toContain(CONVERSATION_BINDING_ERROR); + expect( + telegramCalls.filter((call) => JSON.stringify(call).includes(String(FOREIGN_CHAT_ID))), + ).toEqual([]); + expect(scriptedCalls).toEqual([ + { scenario: CURRENT_CHAT_SCENARIO, arguments: { action: "emoji-list" } }, + { + scenario: FOREIGN_CHAT_SCENARIO, + arguments: { action: "emoji-list", chatId: String(FOREIGN_CHAT_ID) }, + }, + ]); + } finally { + await settleCleanup( + async () => await gateway?.stop(), + async () => await mock?.stop(), + ); + } + }), + ); +}, 120_000);