mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-24 11:25:50 -06:00
feat(channels): custom emoji discovery via emoji-list across Discord, Slack, Telegram (#128435)
* feat(channels): custom emoji discovery via emoji-list across Discord, Slack, Telegram
Make custom emojis discoverable by the agent. The message tool's emoji
param now documents custom-emoji syntax per channel (gate-aware, only
naming emoji-list when the action is actually advertised). Discord
emoji-list defaults guildId from the current conversation and returns
reaction-ready { name, identifier, animated? } entries; Slack returns
normalized shortcodes with aliasOf. Telegram gains emoji-list backed by
one canonical allowed-reactions owner (getChat available_reactions,
custom_emoji entries preserved), numeric custom-emoji reactions, and
replaces the dead 'reaction disallow list' error advice with a bounded
sample of the chat's allowed reactions.
* test(channels): expect telegram emoji-list provider-owned read gate in plugin shape contract
* test(telegram): prove emoji-list authority chain via mock-gateway e2e
Ephemeral gateway + mock Bot API + mock OpenAI provider: current-chat
emoji-list returns normalized standard and custom_emoji identifiers with
exactly one getChat call; a delegated cross-chat request is rejected with
the conversation-binding error and zero Bot API requests reference the
foreign chat.
This commit is contained in:
committed by
GitHub
parent
d2eb92adcf
commit
2aa5eee34e
@@ -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 `<a:name:id>` 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.*`.
|
||||
|
||||
@@ -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
|
||||
|
||||
<Tabs>
|
||||
|
||||
@@ -583,15 +583,30 @@ curl "https://api.telegram.org/bot<bot_token>/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).
|
||||
|
||||
</Accordion>
|
||||
|
||||
@@ -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:<id>` (DM) or `channel:<id>` (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
|
||||
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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<DiscordChannelInfoTest[]> => []),
|
||||
listGuildEmojisDiscord: vi.fn(async () => []),
|
||||
listGuildEmojisDiscord: vi.fn(async (): Promise<RESTGetAPIGuildEmojisResult> => []),
|
||||
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",
|
||||
|
||||
@@ -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>.*<a:name:id>.*emoji-list/,
|
||||
),
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it.each(["read", "search", "edit", "delete", "react", "pin", "channel-info"])(
|
||||
|
||||
@@ -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> / <a:name:id>).${actions.has("emoji-list") ? ' Use action:"emoji-list" for server emojis.' : ""}`,
|
||||
}),
|
||||
),
|
||||
},
|
||||
actions: ["react", "reactions"],
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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<APIGuild> {
|
||||
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<unknown> {
|
||||
return await rest.get(Routes.guildEmojis(guildId));
|
||||
export async function listGuildEmojis(
|
||||
rest: RequestClient,
|
||||
guildId: string,
|
||||
): Promise<RESTGetAPIGuildEmojisResult> {
|
||||
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(
|
||||
|
||||
@@ -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" }],
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -21,12 +21,16 @@ function createSlackFileActionSchema(): Record<string, TSchema> {
|
||||
};
|
||||
}
|
||||
|
||||
function createSlackReactionEmojiSchema(): Record<string, TSchema> {
|
||||
function createSlackReactionEmojiSchema(emojiListAvailable: boolean): Record<string, TSchema> {
|
||||
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"],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -34,6 +34,9 @@ function handleTelegramAction(
|
||||
});
|
||||
}
|
||||
const reactMessageTelegram = vi.fn(async () => ({ ok: true }));
|
||||
const getTelegramAllowedReactions = vi.fn<typeof telegramActionRuntime.getTelegramAllowedReactions>(
|
||||
async () => null,
|
||||
);
|
||||
const sendMessageTelegram = vi.fn(
|
||||
async (_to: string, _text: string, _opts?: Record<string, unknown>) => ({
|
||||
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<ReturnType<typeof reactMessageTelegram>>);
|
||||
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");
|
||||
});
|
||||
|
||||
@@ -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<string> {
|
||||
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<string, unknown>,
|
||||
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<ReturnType<typeof telegramActionRuntime.reactMessageTelegram>>;
|
||||
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 }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -225,6 +225,7 @@ describe("buildTelegramMessageContext reactions", () => {
|
||||
type: "private",
|
||||
available_reactions: [
|
||||
{ type: "emoji", emoji: "👍" },
|
||||
{ type: "custom_emoji", custom_emoji_id: "5231419410191111111" },
|
||||
{ type: "emoji", emoji: "❤" },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
|
||||
@@ -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: [] },
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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<TelegramApiCallOpts, "gatewayClientScopes"> &
|
||||
Pick<TelegramSendOpts, "messageThreadId">;
|
||||
|
||||
export async function getTelegramAllowedReactions(
|
||||
chatId: string | number,
|
||||
opts: TelegramApiCallOpts,
|
||||
): ReturnType<typeof resolveTelegramAllowedReactions> {
|
||||
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.");
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ export {
|
||||
} from "./send-context.js";
|
||||
export {
|
||||
deleteMessageTelegram,
|
||||
getTelegramAllowedReactions,
|
||||
pinMessageTelegram,
|
||||
reactMessageTelegram,
|
||||
sendTypingTelegram,
|
||||
|
||||
@@ -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<StatusReactionEmojis>;
|
||||
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<TelegramReactionEmoji> | null | undefined {
|
||||
): TelegramAllowedReaction[] | null | undefined {
|
||||
if (!chat) {
|
||||
return undefined;
|
||||
}
|
||||
@@ -188,41 +189,46 @@ function extractTelegramAllowedEmojiReactions(
|
||||
return null;
|
||||
}
|
||||
if (!Array.isArray(availableReactions)) {
|
||||
return new Set<TelegramReactionEmoji>();
|
||||
return [];
|
||||
}
|
||||
|
||||
const allowed = new Set<TelegramReactionEmoji>();
|
||||
const allowed: TelegramAllowedReaction[] = [];
|
||||
const identifiers = new Set<string>();
|
||||
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<Set<TelegramReactionEmoji> | null> {
|
||||
const fromMessage = extractTelegramAllowedEmojiReactions(params.chat);
|
||||
}): Promise<TelegramAllowedReaction[] | null> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string>());
|
||||
).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");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -3695,12 +3695,26 @@ describe("message tool schema scoping", () => {
|
||||
it.each<{
|
||||
action: ChannelMessageActionName;
|
||||
fields: string[];
|
||||
descriptions?: Record<string, string>;
|
||||
}>([
|
||||
{
|
||||
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", () => {
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
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<string> {
|
||||
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<void>>) {
|
||||
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<ServerResponse>();
|
||||
const scriptedCalls: Array<{ scenario: string; arguments: JsonObject }> = [];
|
||||
let mock: Awaited<ReturnType<typeof startQaMockOpenAiServer>> | 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<ReturnType<typeof startQaGatewayChild>> | 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);
|
||||
Reference in New Issue
Block a user