mirror of https://github.com/openclaw/openclaw.git
Merged via /review-pr -> /prepare-pr -> /merge-pr.
Prepared head SHA: b58492cfed
Co-authored-by: robbyczgw-cla <239660374+robbyczgw-cla@users.noreply.github.com>
Co-authored-by: steipete <58493+steipete@users.noreply.github.com>
Reviewed-by: @steipete
This commit is contained in:
parent
fc5d147d1b
commit
8e5689a84d
|
|
@ -8,6 +8,7 @@ Docs: https://docs.openclaw.ai
|
|||
|
||||
- Sandbox: add `sandbox.browser.binds` to configure browser-container bind mounts separately from exec containers. (#16230) Thanks @seheepeak.
|
||||
- Discord: add debug logging for message routing decisions to improve `--debug` tracing. (#16202) Thanks @jayleekr.
|
||||
- Telegram: add poll sending via `openclaw message poll` (duration seconds, silent delivery, anonymity controls). (#16209) Thanks @robbyczgw-cla.
|
||||
|
||||
### Fixes
|
||||
|
||||
|
|
|
|||
|
|
@ -64,10 +64,11 @@ Name lookup:
|
|||
- WhatsApp only: `--gif-playback`
|
||||
|
||||
- `poll`
|
||||
- Channels: WhatsApp/Discord/MS Teams
|
||||
- Channels: WhatsApp/Telegram/Discord/Matrix/MS Teams
|
||||
- Required: `--target`, `--poll-question`, `--poll-option` (repeat)
|
||||
- Optional: `--poll-multi`
|
||||
- Discord only: `--poll-duration-hours`, `--message`
|
||||
- Discord only: `--poll-duration-hours`, `--silent`, `--message`
|
||||
- Telegram only: `--poll-duration-seconds` (5-600), `--silent`, `--poll-anonymous` / `--poll-public`, `--thread-id`
|
||||
|
||||
- `react`
|
||||
- Channels: Discord/Google Chat/Slack/Telegram/WhatsApp/Signal
|
||||
|
|
@ -200,6 +201,16 @@ openclaw message poll --channel discord \
|
|||
--poll-multi --poll-duration-hours 48
|
||||
```
|
||||
|
||||
Create a Telegram poll (auto-close in 2 minutes):
|
||||
|
||||
```
|
||||
openclaw message poll --channel telegram \
|
||||
--target @mychat \
|
||||
--poll-question "Lunch?" \
|
||||
--poll-option Pizza --poll-option Sushi \
|
||||
--poll-duration-seconds 120 --silent
|
||||
```
|
||||
|
||||
Send a Teams proactive message:
|
||||
|
||||
```
|
||||
|
|
|
|||
|
|
@ -285,28 +285,31 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
|
|||
chunker: null,
|
||||
textChunkLimit: 2000,
|
||||
pollMaxOptions: 10,
|
||||
sendText: async ({ to, text, accountId, deps, replyToId }) => {
|
||||
sendText: async ({ to, text, accountId, deps, replyToId, silent }) => {
|
||||
const send = deps?.sendDiscord ?? getDiscordRuntime().channel.discord.sendMessageDiscord;
|
||||
const result = await send(to, text, {
|
||||
verbose: false,
|
||||
replyTo: replyToId ?? undefined,
|
||||
accountId: accountId ?? undefined,
|
||||
silent: silent ?? undefined,
|
||||
});
|
||||
return { channel: "discord", ...result };
|
||||
},
|
||||
sendMedia: async ({ to, text, mediaUrl, accountId, deps, replyToId }) => {
|
||||
sendMedia: async ({ to, text, mediaUrl, accountId, deps, replyToId, silent }) => {
|
||||
const send = deps?.sendDiscord ?? getDiscordRuntime().channel.discord.sendMessageDiscord;
|
||||
const result = await send(to, text, {
|
||||
verbose: false,
|
||||
mediaUrl,
|
||||
replyTo: replyToId ?? undefined,
|
||||
accountId: accountId ?? undefined,
|
||||
silent: silent ?? undefined,
|
||||
});
|
||||
return { channel: "discord", ...result };
|
||||
},
|
||||
sendPoll: async ({ to, poll, accountId }) =>
|
||||
sendPoll: async ({ to, poll, accountId, silent }) =>
|
||||
await getDiscordRuntime().channel.discord.sendPollDiscord(to, poll, {
|
||||
accountId: accountId ?? undefined,
|
||||
silent: silent ?? undefined,
|
||||
}),
|
||||
},
|
||||
status: {
|
||||
|
|
|
|||
|
|
@ -96,6 +96,7 @@ export const telegramPlugin: ChannelPlugin<ResolvedTelegramAccount, TelegramProb
|
|||
reactions: true,
|
||||
threads: true,
|
||||
media: true,
|
||||
polls: true,
|
||||
nativeCommands: true,
|
||||
blockStreaming: true,
|
||||
},
|
||||
|
|
@ -273,7 +274,8 @@ export const telegramPlugin: ChannelPlugin<ResolvedTelegramAccount, TelegramProb
|
|||
chunker: (text, limit) => getTelegramRuntime().channel.text.chunkMarkdownText(text, limit),
|
||||
chunkerMode: "markdown",
|
||||
textChunkLimit: 4000,
|
||||
sendText: async ({ to, text, accountId, deps, replyToId, threadId }) => {
|
||||
pollMaxOptions: 10,
|
||||
sendText: async ({ to, text, accountId, deps, replyToId, threadId, silent }) => {
|
||||
const send = deps?.sendTelegram ?? getTelegramRuntime().channel.telegram.sendMessageTelegram;
|
||||
const replyToMessageId = parseReplyToMessageId(replyToId);
|
||||
const messageThreadId = parseThreadId(threadId);
|
||||
|
|
@ -282,10 +284,11 @@ export const telegramPlugin: ChannelPlugin<ResolvedTelegramAccount, TelegramProb
|
|||
messageThreadId,
|
||||
replyToMessageId,
|
||||
accountId: accountId ?? undefined,
|
||||
silent: silent ?? undefined,
|
||||
});
|
||||
return { channel: "telegram", ...result };
|
||||
},
|
||||
sendMedia: async ({ to, text, mediaUrl, accountId, deps, replyToId, threadId }) => {
|
||||
sendMedia: async ({ to, text, mediaUrl, accountId, deps, replyToId, threadId, silent }) => {
|
||||
const send = deps?.sendTelegram ?? getTelegramRuntime().channel.telegram.sendMessageTelegram;
|
||||
const replyToMessageId = parseReplyToMessageId(replyToId);
|
||||
const messageThreadId = parseThreadId(threadId);
|
||||
|
|
@ -295,9 +298,17 @@ export const telegramPlugin: ChannelPlugin<ResolvedTelegramAccount, TelegramProb
|
|||
messageThreadId,
|
||||
replyToMessageId,
|
||||
accountId: accountId ?? undefined,
|
||||
silent: silent ?? undefined,
|
||||
});
|
||||
return { channel: "telegram", ...result };
|
||||
},
|
||||
sendPoll: async ({ to, poll, accountId, threadId, silent, isAnonymous }) =>
|
||||
await getTelegramRuntime().channel.telegram.sendPollTelegram(to, poll, {
|
||||
accountId: accountId ?? undefined,
|
||||
messageThreadId: parseThreadId(threadId),
|
||||
silent: silent ?? undefined,
|
||||
isAnonymous: isAnonymous ?? undefined,
|
||||
}),
|
||||
},
|
||||
status: {
|
||||
defaultRuntime: {
|
||||
|
|
|
|||
|
|
@ -27,8 +27,9 @@ export const discordOutbound: ChannelOutboundAdapter = {
|
|||
});
|
||||
return { channel: "discord", ...result };
|
||||
},
|
||||
sendPoll: async ({ to, poll, accountId }) =>
|
||||
sendPoll: async ({ to, poll, accountId, silent }) =>
|
||||
await sendPollDiscord(to, poll, {
|
||||
accountId: accountId ?? undefined,
|
||||
silent: silent ?? undefined,
|
||||
}),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -344,4 +344,6 @@ export type ChannelPollContext = {
|
|||
poll: PollInput;
|
||||
accountId?: string | null;
|
||||
threadId?: string | null;
|
||||
silent?: boolean;
|
||||
isAnonymous?: boolean;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -15,8 +15,17 @@ export function registerMessagePollCommand(message: Command, helpers: MessageCli
|
|||
[] as string[],
|
||||
)
|
||||
.option("--poll-multi", "Allow multiple selections", false)
|
||||
.option("--poll-duration-hours <n>", "Poll duration (Discord)")
|
||||
.option("--poll-duration-hours <n>", "Poll duration in hours (Discord)")
|
||||
.option("--poll-duration-seconds <n>", "Poll duration in seconds (Telegram; 5-600)")
|
||||
.option("--poll-anonymous", "Send an anonymous poll (Telegram)", false)
|
||||
.option("--poll-public", "Send a non-anonymous poll (Telegram)", false)
|
||||
.option("-m, --message <text>", "Optional message body")
|
||||
.option(
|
||||
"--silent",
|
||||
"Send poll silently without notification (Telegram + Discord where supported)",
|
||||
false,
|
||||
)
|
||||
.option("--thread-id <id>", "Thread id (Telegram forum topic / Slack thread ts)")
|
||||
.action(async (opts) => {
|
||||
await helpers.runMessageAction("poll", opts);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -274,12 +274,15 @@ export async function sendPollDiscord(
|
|||
const { channelId } = await resolveChannelId(rest, recipient, request);
|
||||
const content = opts.content?.trim();
|
||||
const payload = normalizeDiscordPollInput(poll);
|
||||
// Discord message flag for silent/suppress notifications (matches send.shared.ts)
|
||||
const flags = opts.silent ? 1 << 12 : undefined;
|
||||
const res = (await request(
|
||||
() =>
|
||||
rest.post(Routes.channelMessages(channelId), {
|
||||
body: {
|
||||
content: content || undefined,
|
||||
poll: payload,
|
||||
...(flags ? { flags } : {}),
|
||||
},
|
||||
}) as Promise<{ id: string; channel_id: string }>,
|
||||
"poll",
|
||||
|
|
|
|||
|
|
@ -35,7 +35,15 @@ export const PollParamsSchema = Type.Object(
|
|||
question: NonEmptyString,
|
||||
options: Type.Array(NonEmptyString, { minItems: 2, maxItems: 12 }),
|
||||
maxSelections: Type.Optional(Type.Integer({ minimum: 1, maximum: 12 })),
|
||||
/** Poll duration in seconds (channel-specific limits may apply). */
|
||||
durationSeconds: Type.Optional(Type.Integer({ minimum: 1, maximum: 600 })),
|
||||
durationHours: Type.Optional(Type.Integer({ minimum: 1 })),
|
||||
/** Send silently (no notification) where supported. */
|
||||
silent: Type.Optional(Type.Boolean()),
|
||||
/** Poll anonymity where supported (e.g. Telegram polls default to anonymous). */
|
||||
isAnonymous: Type.Optional(Type.Boolean()),
|
||||
/** Thread id (channel-specific meaning, e.g. Telegram forum topic id). */
|
||||
threadId: Type.Optional(Type.String()),
|
||||
channel: Type.Optional(Type.String()),
|
||||
accountId: Type.Optional(Type.String()),
|
||||
idempotencyKey: NonEmptyString,
|
||||
|
|
|
|||
|
|
@ -274,7 +274,11 @@ export const sendHandlers: GatewayRequestHandlers = {
|
|||
question: string;
|
||||
options: string[];
|
||||
maxSelections?: number;
|
||||
durationSeconds?: number;
|
||||
durationHours?: number;
|
||||
silent?: boolean;
|
||||
isAnonymous?: boolean;
|
||||
threadId?: string;
|
||||
channel?: string;
|
||||
accountId?: string;
|
||||
idempotencyKey: string;
|
||||
|
|
@ -303,8 +307,13 @@ export const sendHandlers: GatewayRequestHandlers = {
|
|||
question: request.question,
|
||||
options: request.options,
|
||||
maxSelections: request.maxSelections,
|
||||
durationSeconds: request.durationSeconds,
|
||||
durationHours: request.durationHours,
|
||||
};
|
||||
const threadId =
|
||||
typeof request.threadId === "string" && request.threadId.trim().length
|
||||
? request.threadId.trim()
|
||||
: undefined;
|
||||
const accountId =
|
||||
typeof request.accountId === "string" && request.accountId.trim().length
|
||||
? request.accountId.trim()
|
||||
|
|
@ -340,6 +349,9 @@ export const sendHandlers: GatewayRequestHandlers = {
|
|||
to: resolved.to,
|
||||
poll: normalized,
|
||||
accountId,
|
||||
threadId,
|
||||
silent: request.silent,
|
||||
isAnonymous: request.isAnonymous,
|
||||
});
|
||||
const payload: Record<string, unknown> = {
|
||||
runId: idem,
|
||||
|
|
|
|||
|
|
@ -568,11 +568,36 @@ async function handlePollAction(ctx: ResolvedActionContext): Promise<MessageActi
|
|||
if (options.length < 2) {
|
||||
throw new Error("pollOption requires at least two values");
|
||||
}
|
||||
const silent = readBooleanParam(params, "silent");
|
||||
const allowMultiselect = readBooleanParam(params, "pollMulti") ?? false;
|
||||
const pollAnonymous = readBooleanParam(params, "pollAnonymous");
|
||||
const pollPublic = readBooleanParam(params, "pollPublic");
|
||||
if (pollAnonymous && pollPublic) {
|
||||
throw new Error("pollAnonymous and pollPublic are mutually exclusive");
|
||||
}
|
||||
const isAnonymous = pollAnonymous ? true : pollPublic ? false : undefined;
|
||||
const durationHours = readNumberParam(params, "pollDurationHours", {
|
||||
integer: true,
|
||||
});
|
||||
const durationSeconds = readNumberParam(params, "pollDurationSeconds", {
|
||||
integer: true,
|
||||
});
|
||||
const maxSelections = allowMultiselect ? Math.max(2, options.length) : 1;
|
||||
|
||||
const threadId = readStringParam(params, "threadId");
|
||||
const slackAutoThreadId =
|
||||
channel === "slack" && !threadId
|
||||
? resolveSlackAutoThreadId({ to, toolContext: input.toolContext })
|
||||
: undefined;
|
||||
const telegramAutoThreadId =
|
||||
channel === "telegram" && !threadId
|
||||
? resolveTelegramAutoThreadId({ to, toolContext: input.toolContext })
|
||||
: undefined;
|
||||
const resolvedThreadId = threadId ?? slackAutoThreadId ?? telegramAutoThreadId;
|
||||
if (resolvedThreadId && !params.threadId) {
|
||||
params.threadId = resolvedThreadId;
|
||||
}
|
||||
|
||||
const base = typeof params.message === "string" ? params.message : "";
|
||||
await maybeApplyCrossContextMarker({
|
||||
cfg,
|
||||
|
|
@ -595,12 +620,16 @@ async function handlePollAction(ctx: ResolvedActionContext): Promise<MessageActi
|
|||
gateway,
|
||||
toolContext: input.toolContext,
|
||||
dryRun,
|
||||
silent: silent ?? undefined,
|
||||
},
|
||||
to,
|
||||
question,
|
||||
options,
|
||||
maxSelections,
|
||||
durationSeconds: durationSeconds ?? undefined,
|
||||
durationHours: durationHours ?? undefined,
|
||||
threadId: resolvedThreadId ?? undefined,
|
||||
isAnonymous,
|
||||
});
|
||||
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -69,8 +69,13 @@ type MessagePollParams = {
|
|||
question: string;
|
||||
options: string[];
|
||||
maxSelections?: number;
|
||||
durationSeconds?: number;
|
||||
durationHours?: number;
|
||||
channel?: string;
|
||||
accountId?: string;
|
||||
threadId?: string;
|
||||
silent?: boolean;
|
||||
isAnonymous?: boolean;
|
||||
dryRun?: boolean;
|
||||
cfg?: OpenClawConfig;
|
||||
gateway?: MessageGatewayOptions;
|
||||
|
|
@ -83,6 +88,7 @@ export type MessagePollResult = {
|
|||
question: string;
|
||||
options: string[];
|
||||
maxSelections: number;
|
||||
durationSeconds: number | null;
|
||||
durationHours: number | null;
|
||||
via: "gateway";
|
||||
result?: {
|
||||
|
|
@ -239,6 +245,7 @@ export async function sendPoll(params: MessagePollParams): Promise<MessagePollRe
|
|||
question: params.question,
|
||||
options: params.options,
|
||||
maxSelections: params.maxSelections,
|
||||
durationSeconds: params.durationSeconds,
|
||||
durationHours: params.durationHours,
|
||||
};
|
||||
const plugin = getChannelPlugin(channel);
|
||||
|
|
@ -257,6 +264,7 @@ export async function sendPoll(params: MessagePollParams): Promise<MessagePollRe
|
|||
question: normalized.question,
|
||||
options: normalized.options,
|
||||
maxSelections: normalized.maxSelections,
|
||||
durationSeconds: normalized.durationSeconds ?? null,
|
||||
durationHours: normalized.durationHours ?? null,
|
||||
via: "gateway",
|
||||
dryRun: true,
|
||||
|
|
@ -279,8 +287,13 @@ export async function sendPoll(params: MessagePollParams): Promise<MessagePollRe
|
|||
question: normalized.question,
|
||||
options: normalized.options,
|
||||
maxSelections: normalized.maxSelections,
|
||||
durationSeconds: normalized.durationSeconds,
|
||||
durationHours: normalized.durationHours,
|
||||
threadId: params.threadId,
|
||||
silent: params.silent,
|
||||
isAnonymous: params.isAnonymous,
|
||||
channel,
|
||||
accountId: params.accountId,
|
||||
idempotencyKey: params.idempotencyKey ?? randomIdempotencyKey(),
|
||||
},
|
||||
timeoutMs: gateway.timeoutMs,
|
||||
|
|
@ -295,6 +308,7 @@ export async function sendPoll(params: MessagePollParams): Promise<MessagePollRe
|
|||
question: normalized.question,
|
||||
options: normalized.options,
|
||||
maxSelections: normalized.maxSelections,
|
||||
durationSeconds: normalized.durationSeconds ?? null,
|
||||
durationHours: normalized.durationHours ?? null,
|
||||
via: "gateway",
|
||||
result,
|
||||
|
|
|
|||
|
|
@ -145,7 +145,10 @@ export async function executePollAction(params: {
|
|||
question: string;
|
||||
options: string[];
|
||||
maxSelections: number;
|
||||
durationSeconds?: number;
|
||||
durationHours?: number;
|
||||
threadId?: string;
|
||||
isAnonymous?: boolean;
|
||||
}): Promise<{
|
||||
handledBy: "plugin" | "core";
|
||||
payload: unknown;
|
||||
|
|
@ -178,8 +181,13 @@ export async function executePollAction(params: {
|
|||
question: params.question,
|
||||
options: params.options,
|
||||
maxSelections: params.maxSelections,
|
||||
durationSeconds: params.durationSeconds ?? undefined,
|
||||
durationHours: params.durationHours ?? undefined,
|
||||
channel: params.ctx.channel,
|
||||
accountId: params.ctx.accountId ?? undefined,
|
||||
threadId: params.threadId ?? undefined,
|
||||
silent: params.ctx.silent ?? undefined,
|
||||
isAnonymous: params.isAnonymous ?? undefined,
|
||||
dryRun: params.ctx.dryRun,
|
||||
gateway: params.ctx.gateway,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -126,7 +126,7 @@ import {
|
|||
} from "../../telegram/audit.js";
|
||||
import { monitorTelegramProvider } from "../../telegram/monitor.js";
|
||||
import { probeTelegram } from "../../telegram/probe.js";
|
||||
import { sendMessageTelegram } from "../../telegram/send.js";
|
||||
import { sendMessageTelegram, sendPollTelegram } from "../../telegram/send.js";
|
||||
import { resolveTelegramToken } from "../../telegram/token.js";
|
||||
import { textToSpeechTelephony } from "../../tts/tts.js";
|
||||
import { getActiveWebListener } from "../../web/active-listener.js";
|
||||
|
|
@ -363,6 +363,7 @@ export function createPluginRuntime(): PluginRuntime {
|
|||
probeTelegram,
|
||||
resolveTelegramToken,
|
||||
sendMessageTelegram,
|
||||
sendPollTelegram,
|
||||
monitorTelegramProvider,
|
||||
messageActions: telegramMessageActions,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -120,6 +120,7 @@ type CollectTelegramUnmentionedGroupIds =
|
|||
type ProbeTelegram = typeof import("../../telegram/probe.js").probeTelegram;
|
||||
type ResolveTelegramToken = typeof import("../../telegram/token.js").resolveTelegramToken;
|
||||
type SendMessageTelegram = typeof import("../../telegram/send.js").sendMessageTelegram;
|
||||
type SendPollTelegram = typeof import("../../telegram/send.js").sendPollTelegram;
|
||||
type MonitorTelegramProvider = typeof import("../../telegram/monitor.js").monitorTelegramProvider;
|
||||
type TelegramMessageActions =
|
||||
typeof import("../../channels/plugins/actions/telegram.js").telegramMessageActions;
|
||||
|
|
@ -301,6 +302,7 @@ export type PluginRuntime = {
|
|||
probeTelegram: ProbeTelegram;
|
||||
resolveTelegramToken: ResolveTelegramToken;
|
||||
sendMessageTelegram: SendMessageTelegram;
|
||||
sendPollTelegram: SendPollTelegram;
|
||||
monitorTelegramProvider: MonitorTelegramProvider;
|
||||
messageActions: TelegramMessageActions;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ describe("polls", () => {
|
|||
question: "Lunch?",
|
||||
options: ["Pizza", "Sushi"],
|
||||
maxSelections: 2,
|
||||
durationSeconds: undefined,
|
||||
durationHours: undefined,
|
||||
});
|
||||
});
|
||||
|
|
|
|||
21
src/polls.ts
21
src/polls.ts
|
|
@ -2,6 +2,15 @@ export type PollInput = {
|
|||
question: string;
|
||||
options: string[];
|
||||
maxSelections?: number;
|
||||
/**
|
||||
* Poll duration in seconds.
|
||||
* Channel-specific limits apply (e.g. Telegram open_period is 5-600s).
|
||||
*/
|
||||
durationSeconds?: number;
|
||||
/**
|
||||
* Poll duration in hours.
|
||||
* Used by channels that model duration in hours (e.g. Discord).
|
||||
*/
|
||||
durationHours?: number;
|
||||
};
|
||||
|
||||
|
|
@ -9,6 +18,7 @@ export type NormalizedPollInput = {
|
|||
question: string;
|
||||
options: string[];
|
||||
maxSelections: number;
|
||||
durationSeconds?: number;
|
||||
durationHours?: number;
|
||||
};
|
||||
|
||||
|
|
@ -43,6 +53,16 @@ export function normalizePollInput(
|
|||
if (maxSelections > cleaned.length) {
|
||||
throw new Error("maxSelections cannot exceed option count");
|
||||
}
|
||||
|
||||
const durationSecondsRaw = input.durationSeconds;
|
||||
const durationSeconds =
|
||||
typeof durationSecondsRaw === "number" && Number.isFinite(durationSecondsRaw)
|
||||
? Math.floor(durationSecondsRaw)
|
||||
: undefined;
|
||||
if (durationSeconds !== undefined && durationSeconds < 1) {
|
||||
throw new Error("durationSeconds must be at least 1");
|
||||
}
|
||||
|
||||
const durationRaw = input.durationHours;
|
||||
const durationHours =
|
||||
typeof durationRaw === "number" && Number.isFinite(durationRaw)
|
||||
|
|
@ -55,6 +75,7 @@ export function normalizePollInput(
|
|||
question,
|
||||
options: cleaned,
|
||||
maxSelections,
|
||||
durationSeconds,
|
||||
durationHours,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
export { createTelegramBot, createTelegramWebhookCallback } from "./bot.js";
|
||||
export { monitorTelegramProvider } from "./monitor.js";
|
||||
export { reactMessageTelegram, sendMessageTelegram } from "./send.js";
|
||||
export { reactMessageTelegram, sendMessageTelegram, sendPollTelegram } from "./send.js";
|
||||
export { startTelegramWebhook } from "./webhook.js";
|
||||
|
|
|
|||
|
|
@ -0,0 +1,63 @@
|
|||
import type { Bot } from "grammy";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { sendPollTelegram } from "./send.js";
|
||||
|
||||
describe("sendPollTelegram", () => {
|
||||
it("maps durationSeconds to open_period", async () => {
|
||||
const api = {
|
||||
sendPoll: vi.fn(async () => ({ message_id: 123, chat: { id: 555 }, poll: { id: "p1" } })),
|
||||
};
|
||||
|
||||
const res = await sendPollTelegram(
|
||||
"123",
|
||||
{ question: " Q ", options: [" A ", "B "], durationSeconds: 60 },
|
||||
{ token: "t", api: api as unknown as Bot["api"] },
|
||||
);
|
||||
|
||||
expect(res).toEqual({ messageId: "123", chatId: "555", pollId: "p1" });
|
||||
expect(api.sendPoll).toHaveBeenCalledTimes(1);
|
||||
expect(api.sendPoll.mock.calls[0]?.[0]).toBe("123");
|
||||
expect(api.sendPoll.mock.calls[0]?.[1]).toBe("Q");
|
||||
expect(api.sendPoll.mock.calls[0]?.[2]).toEqual(["A", "B"]);
|
||||
expect(api.sendPoll.mock.calls[0]?.[3]).toMatchObject({ open_period: 60 });
|
||||
});
|
||||
|
||||
it("retries without message_thread_id on thread-not-found", async () => {
|
||||
const api = {
|
||||
sendPoll: vi.fn(
|
||||
async (_chatId: string, _question: string, _options: string[], params: unknown) => {
|
||||
const p = params as { message_thread_id?: unknown } | undefined;
|
||||
if (p?.message_thread_id) {
|
||||
throw new Error("400: Bad Request: message thread not found");
|
||||
}
|
||||
return { message_id: 1, chat: { id: 2 }, poll: { id: "p2" } };
|
||||
},
|
||||
),
|
||||
};
|
||||
|
||||
const res = await sendPollTelegram(
|
||||
"123",
|
||||
{ question: "Q", options: ["A", "B"] },
|
||||
{ token: "t", api: api as unknown as Bot["api"], messageThreadId: 99 },
|
||||
);
|
||||
|
||||
expect(res).toEqual({ messageId: "1", chatId: "2", pollId: "p2" });
|
||||
expect(api.sendPoll).toHaveBeenCalledTimes(2);
|
||||
expect(api.sendPoll.mock.calls[0]?.[3]).toMatchObject({ message_thread_id: 99 });
|
||||
expect(api.sendPoll.mock.calls[1]?.[3]?.message_thread_id).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects durationHours for Telegram polls", async () => {
|
||||
const api = { sendPoll: vi.fn() };
|
||||
|
||||
await expect(
|
||||
sendPollTelegram(
|
||||
"123",
|
||||
{ question: "Q", options: ["A", "B"], durationHours: 1 },
|
||||
{ token: "t", api: api as unknown as Bot["api"] },
|
||||
),
|
||||
).rejects.toThrow(/durationHours is not supported/i);
|
||||
|
||||
expect(api.sendPoll).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
@ -17,6 +17,7 @@ import { redactSensitiveText } from "../logging/redact.js";
|
|||
import { createSubsystemLogger } from "../logging/subsystem.js";
|
||||
import { mediaKindFromMime } from "../media/constants.js";
|
||||
import { isGifMedia } from "../media/mime.js";
|
||||
import { normalizePollInput, type PollInput } from "../polls.js";
|
||||
import { loadWebMedia } from "../web/media.js";
|
||||
import { type ResolvedTelegramAccount, resolveTelegramAccount } from "./accounts.js";
|
||||
import { withTelegramApiErrorLogging } from "./api-logging.js";
|
||||
|
|
@ -923,3 +924,154 @@ export async function sendStickerTelegram(
|
|||
|
||||
return { messageId, chatId: resolvedChatId };
|
||||
}
|
||||
|
||||
type TelegramPollOpts = {
|
||||
token?: string;
|
||||
accountId?: string;
|
||||
verbose?: boolean;
|
||||
api?: Bot["api"];
|
||||
retry?: RetryConfig;
|
||||
/** Message ID to reply to (for threading) */
|
||||
replyToMessageId?: number;
|
||||
/** Forum topic thread ID (for forum supergroups) */
|
||||
messageThreadId?: number;
|
||||
/** Send message silently (no notification). Defaults to false. */
|
||||
silent?: boolean;
|
||||
/** Whether votes are anonymous. Defaults to true (Telegram default). */
|
||||
isAnonymous?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Send a poll to a Telegram chat.
|
||||
* @param to - Chat ID or username (e.g., "123456789" or "@username")
|
||||
* @param poll - Poll input with question, options, maxSelections, and optional durationHours
|
||||
* @param opts - Optional configuration
|
||||
*/
|
||||
export async function sendPollTelegram(
|
||||
to: string,
|
||||
poll: PollInput,
|
||||
opts: TelegramPollOpts = {},
|
||||
): Promise<{ messageId: string; chatId: string; pollId?: string }> {
|
||||
const cfg = loadConfig();
|
||||
const account = resolveTelegramAccount({
|
||||
cfg,
|
||||
accountId: opts.accountId,
|
||||
});
|
||||
const token = resolveToken(opts.token, account);
|
||||
const target = parseTelegramTarget(to);
|
||||
const chatId = normalizeChatId(target.chatId);
|
||||
const client = resolveTelegramClientOptions(account);
|
||||
const api = opts.api ?? new Bot(token, client ? { client } : undefined).api;
|
||||
|
||||
// Normalize the poll input (validates question, options, maxSelections)
|
||||
const normalizedPoll = normalizePollInput(poll, { maxOptions: 10 });
|
||||
|
||||
const messageThreadId =
|
||||
opts.messageThreadId != null ? opts.messageThreadId : target.messageThreadId;
|
||||
const threadSpec =
|
||||
messageThreadId != null ? { id: messageThreadId, scope: "forum" as const } : undefined;
|
||||
const threadIdParams = buildTelegramThreadParams(threadSpec);
|
||||
|
||||
// Build poll options as simple strings (Grammy accepts string[] or InputPollOption[])
|
||||
const pollOptions = normalizedPoll.options;
|
||||
|
||||
const request = createTelegramRetryRunner({
|
||||
retry: opts.retry,
|
||||
configRetry: account.config.retry,
|
||||
verbose: opts.verbose,
|
||||
shouldRetry: (err) => isRecoverableTelegramNetworkError(err, { context: "send" }),
|
||||
});
|
||||
const logHttpError = createTelegramHttpLogger(cfg);
|
||||
const requestWithDiag = <T>(fn: () => Promise<T>, label?: string) =>
|
||||
withTelegramApiErrorLogging({
|
||||
operation: label ?? "request",
|
||||
fn: () => request(fn, label),
|
||||
}).catch((err) => {
|
||||
logHttpError(label ?? "request", err);
|
||||
throw err;
|
||||
});
|
||||
|
||||
const wrapChatNotFound = (err: unknown) => {
|
||||
if (!/400: Bad Request: chat not found/i.test(formatErrorMessage(err))) {
|
||||
return err;
|
||||
}
|
||||
return new Error(
|
||||
[
|
||||
`Telegram send failed: chat not found (chat_id=${chatId}).`,
|
||||
"Likely: bot not started in DM, bot removed from group/channel, group migrated (new -100… id), or wrong bot token.",
|
||||
`Input was: ${JSON.stringify(to)}.`,
|
||||
].join(" "),
|
||||
);
|
||||
};
|
||||
|
||||
const sendWithThreadFallback = async <T>(
|
||||
params: Record<string, unknown> | undefined,
|
||||
label: string,
|
||||
attempt: (
|
||||
effectiveParams: Record<string, unknown> | undefined,
|
||||
effectiveLabel: string,
|
||||
) => Promise<T>,
|
||||
): Promise<T> => {
|
||||
try {
|
||||
return await attempt(params, label);
|
||||
} catch (err) {
|
||||
if (!hasMessageThreadIdParam(params) || !isTelegramThreadNotFoundError(err)) {
|
||||
throw err;
|
||||
}
|
||||
if (opts.verbose) {
|
||||
console.warn(
|
||||
`telegram ${label} failed with message_thread_id, retrying without thread: ${formatErrorMessage(err)}`,
|
||||
);
|
||||
}
|
||||
const retriedParams = removeMessageThreadIdParam(params);
|
||||
return await attempt(retriedParams, `${label}-threadless`);
|
||||
}
|
||||
};
|
||||
|
||||
const durationSeconds = normalizedPoll.durationSeconds;
|
||||
if (durationSeconds === undefined && normalizedPoll.durationHours !== undefined) {
|
||||
throw new Error(
|
||||
"Telegram poll durationHours is not supported. Use durationSeconds (5-600) instead.",
|
||||
);
|
||||
}
|
||||
if (durationSeconds !== undefined && (durationSeconds < 5 || durationSeconds > 600)) {
|
||||
throw new Error("Telegram poll durationSeconds must be between 5 and 600");
|
||||
}
|
||||
|
||||
// Build poll parameters following Grammy's api.sendPoll signature
|
||||
// sendPoll(chat_id, question, options, other?, signal?)
|
||||
const pollParams = {
|
||||
allows_multiple_answers: normalizedPoll.maxSelections > 1,
|
||||
is_anonymous: opts.isAnonymous ?? true,
|
||||
...(durationSeconds !== undefined ? { open_period: durationSeconds } : {}),
|
||||
...(threadIdParams ? threadIdParams : {}),
|
||||
...(opts.replyToMessageId != null
|
||||
? { reply_to_message_id: Math.trunc(opts.replyToMessageId) }
|
||||
: {}),
|
||||
...(opts.silent === true ? { disable_notification: true } : {}),
|
||||
};
|
||||
|
||||
const result = await sendWithThreadFallback(pollParams, "poll", async (effectiveParams, label) =>
|
||||
requestWithDiag(
|
||||
() => api.sendPoll(chatId, normalizedPoll.question, pollOptions, effectiveParams),
|
||||
label,
|
||||
).catch((err) => {
|
||||
throw wrapChatNotFound(err);
|
||||
}),
|
||||
);
|
||||
|
||||
const messageId = String(result?.message_id ?? "unknown");
|
||||
const resolvedChatId = String(result?.chat?.id ?? chatId);
|
||||
const pollId = result?.poll?.id;
|
||||
if (result?.message_id) {
|
||||
recordSentMessage(chatId, result.message_id);
|
||||
}
|
||||
|
||||
recordChannelActivity({
|
||||
channel: "telegram",
|
||||
accountId: account.accountId,
|
||||
direction: "outbound",
|
||||
});
|
||||
|
||||
return { messageId, chatId: resolvedChatId, pollId };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -149,6 +149,7 @@ describe("web outbound", () => {
|
|||
question: "Lunch?",
|
||||
options: ["Pizza", "Sushi"],
|
||||
maxSelections: 2,
|
||||
durationSeconds: undefined,
|
||||
durationHours: undefined,
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue