Merge pull request #964 from bohdanpodvirnyi/feat/telegram-reactions

feat(telegram): add bidirectional emoji reactions support
This commit is contained in:
Peter Steinberger
2026-01-15 17:21:51 +00:00
committed by GitHub
24 changed files with 3012 additions and 24 deletions

View File

@@ -9,6 +9,7 @@ import { createAgentSession, SessionManager, SettingsManager } from "@mariozechn
import { resolveHeartbeatPrompt } from "../../../auto-reply/heartbeat.js";
import { resolveChannelCapabilities } from "../../../config/channel-capabilities.js";
import { getMachineDisplayName } from "../../../infra/machine-name.js";
import { resolveTelegramReactionLevel } from "../../../telegram/reaction-level.js";
import { normalizeMessageChannel } from "../../../utils/message-channel.js";
import { isReasoningTagProvider } from "../../../utils/provider-utils.js";
import { resolveUserPath } from "../../../utils.js";
@@ -161,6 +162,17 @@ export async function runEmbeddedAttempt(
accountId: params.agentAccountId,
}) ?? [])
: undefined;
const reactionGuidance =
runtimeChannel === "telegram" && params.config
? (() => {
const resolved = resolveTelegramReactionLevel({
cfg: params.config,
accountId: params.agentAccountId ?? undefined,
});
const level = resolved.agentReactionGuidance;
return level ? { level, channel: "Telegram" } : undefined;
})()
: undefined;
const runtimeInfo = {
host: machineName,
os: `${os.type()} ${os.release()}`,
@@ -192,6 +204,7 @@ export async function runEmbeddedAttempt(
? resolveHeartbeatPrompt(params.config?.agents?.defaults?.heartbeat?.prompt)
: undefined,
skillsPrompt,
reactionGuidance,
runtimeInfo,
sandboxInfo,
tools,

View File

@@ -14,6 +14,10 @@ export function buildEmbeddedSystemPrompt(params: {
reasoningTagHint: boolean;
heartbeatPrompt?: string;
skillsPrompt?: string;
reactionGuidance?: {
level: "minimal" | "extensive";
channel: string;
};
runtimeInfo: {
host: string;
os: string;
@@ -40,6 +44,7 @@ export function buildEmbeddedSystemPrompt(params: {
reasoningTagHint: params.reasoningTagHint,
heartbeatPrompt: params.heartbeatPrompt,
skillsPrompt: params.skillsPrompt,
reactionGuidance: params.reactionGuidance,
runtimeInfo: params.runtimeInfo,
sandboxInfo: params.sandboxInfo,
toolNames: params.tools.map((tool) => tool.name),

View File

@@ -208,4 +208,17 @@ describe("buildAgentSystemPrompt", () => {
expect(prompt).toContain("User can toggle with /elevated on|off.");
expect(prompt).toContain("Current elevated level: on");
});
it("includes reaction guidance when provided", () => {
const prompt = buildAgentSystemPrompt({
workspaceDir: "/tmp/clawd",
reactionGuidance: {
level: "minimal",
channel: "Telegram",
},
});
expect(prompt).toContain("## Reactions");
expect(prompt).toContain("Reactions are enabled for Telegram in MINIMAL mode.");
});
});

View File

@@ -43,6 +43,11 @@ export function buildAgentSystemPrompt(params: {
defaultLevel: "on" | "off";
};
};
/** Reaction guidance for the agent (for Telegram minimal/extensive modes). */
reactionGuidance?: {
level: "minimal" | "extensive";
channel: string;
};
}) {
const coreToolSummaries: Record<string, string> = {
read: "Read file contents",
@@ -351,6 +356,29 @@ export function buildAgentSystemPrompt(params: {
if (extraSystemPrompt) {
lines.push("## Group Chat Context", extraSystemPrompt, "");
}
if (params.reactionGuidance) {
const { level, channel } = params.reactionGuidance;
const guidanceText =
level === "minimal"
? [
`Reactions are enabled for ${channel} in MINIMAL mode.`,
"React ONLY when truly relevant:",
"- Acknowledge important user requests or confirmations",
"- Express genuine sentiment (humor, appreciation) sparingly",
"- Avoid reacting to routine messages or your own replies",
"Guideline: at most 1 reaction per 5-10 exchanges.",
].join("\n")
: [
`Reactions are enabled for ${channel} in EXTENSIVE mode.`,
"Feel free to react liberally:",
"- Acknowledge messages with appropriate emojis",
"- Express sentiment and personality through reactions",
"- React to interesting content, humor, or notable events",
"- Use reactions to confirm understanding or agreement",
"Guideline: react whenever it feels natural.",
].join("\n");
lines.push("## Reactions", guidanceText, "");
}
if (reasoningHint) {
lines.push("## Reasoning Format", reasoningHint, "");
}

View File

@@ -33,9 +33,30 @@ describe("handleTelegramAction", () => {
}
});
it("adds reactions", async () => {
it("adds reactions when reactionLevel is minimal", async () => {
const cfg = {
channels: { telegram: { botToken: "tok" } },
channels: { telegram: { botToken: "tok", reactionLevel: "minimal" } },
} as ClawdbotConfig;
await handleTelegramAction(
{
action: "react",
chatId: "123",
messageId: "456",
emoji: "✅",
},
cfg,
);
expect(reactMessageTelegram).toHaveBeenCalledWith(
"123",
456,
"✅",
expect.objectContaining({ token: "tok", remove: false }),
);
});
it("adds reactions when reactionLevel is extensive", async () => {
const cfg = {
channels: { telegram: { botToken: "tok", reactionLevel: "extensive" } },
} as ClawdbotConfig;
await handleTelegramAction(
{
@@ -56,7 +77,7 @@ describe("handleTelegramAction", () => {
it("removes reactions on empty emoji", async () => {
const cfg = {
channels: { telegram: { botToken: "tok" } },
channels: { telegram: { botToken: "tok", reactionLevel: "minimal" } },
} as ClawdbotConfig;
await handleTelegramAction(
{
@@ -77,7 +98,7 @@ describe("handleTelegramAction", () => {
it("removes reactions when remove flag set", async () => {
const cfg = {
channels: { telegram: { botToken: "tok" } },
channels: { telegram: { botToken: "tok", reactionLevel: "extensive" } },
} as ClawdbotConfig;
await handleTelegramAction(
{
@@ -97,10 +118,48 @@ describe("handleTelegramAction", () => {
);
});
it("respects reaction gating", async () => {
it("blocks reactions when reactionLevel is off", async () => {
const cfg = {
channels: { telegram: { botToken: "tok", reactionLevel: "off" } },
} as ClawdbotConfig;
await expect(
handleTelegramAction(
{
action: "react",
chatId: "123",
messageId: "456",
emoji: "✅",
},
cfg,
),
).rejects.toThrow(/Telegram agent reactions disabled.*reactionLevel="off"/);
});
it("blocks reactions when reactionLevel is ack (default)", async () => {
const cfg = {
channels: { telegram: { botToken: "tok", reactionLevel: "ack" } },
} as ClawdbotConfig;
await expect(
handleTelegramAction(
{
action: "react",
chatId: "123",
messageId: "456",
emoji: "✅",
},
cfg,
),
).rejects.toThrow(/Telegram agent reactions disabled.*reactionLevel="ack"/);
});
it("also respects legacy actions.reactions gating", async () => {
const cfg = {
channels: {
telegram: { botToken: "tok", actions: { reactions: false } },
telegram: {
botToken: "tok",
reactionLevel: "minimal",
actions: { reactions: false },
},
},
} as ClawdbotConfig;
await expect(
@@ -113,7 +172,7 @@ describe("handleTelegramAction", () => {
},
cfg,
),
).rejects.toThrow(/Telegram reactions are disabled/);
).rejects.toThrow(/Telegram reactions are disabled via actions.reactions/);
});
it("sends a text message", async () => {

View File

@@ -1,6 +1,7 @@
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
import { resolveChannelCapabilities } from "../../config/channel-capabilities.js";
import type { ClawdbotConfig } from "../../config/config.js";
import { resolveTelegramReactionLevel } from "../../telegram/reaction-level.js";
import {
deleteMessageTelegram,
reactMessageTelegram,
@@ -82,8 +83,20 @@ export async function handleTelegramAction(
const isActionEnabled = createActionGate(cfg.channels?.telegram?.actions);
if (action === "react") {
// Check reaction level first
const reactionLevelInfo = resolveTelegramReactionLevel({
cfg,
accountId: accountId ?? undefined,
});
if (!reactionLevelInfo.agentReactionsEnabled) {
throw new Error(
`Telegram agent reactions disabled (reactionLevel="${reactionLevelInfo.level}"). ` +
`Set channels.telegram.reactionLevel to "minimal" or "extensive" to enable.`,
);
}
// Also check the existing action gate for backward compatibility
if (!isActionEnabled("reactions")) {
throw new Error("Telegram reactions are disabled.");
throw new Error("Telegram reactions are disabled via actions.reactions.");
}
const chatId = readStringOrNumberParam(params, "chatId", {
required: true,