mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-19 11:48:38 +00:00
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:
@@ -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.
|
- 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.
|
- 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
|
### Fixes
|
||||||
|
|
||||||
|
|||||||
@@ -64,10 +64,11 @@ Name lookup:
|
|||||||
- WhatsApp only: `--gif-playback`
|
- WhatsApp only: `--gif-playback`
|
||||||
|
|
||||||
- `poll`
|
- `poll`
|
||||||
- Channels: WhatsApp/Discord/MS Teams
|
- Channels: WhatsApp/Telegram/Discord/Matrix/MS Teams
|
||||||
- Required: `--target`, `--poll-question`, `--poll-option` (repeat)
|
- Required: `--target`, `--poll-question`, `--poll-option` (repeat)
|
||||||
- Optional: `--poll-multi`
|
- 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`
|
- `react`
|
||||||
- Channels: Discord/Google Chat/Slack/Telegram/WhatsApp/Signal
|
- Channels: Discord/Google Chat/Slack/Telegram/WhatsApp/Signal
|
||||||
@@ -200,6 +201,16 @@ openclaw message poll --channel discord \
|
|||||||
--poll-multi --poll-duration-hours 48
|
--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:
|
Send a Teams proactive message:
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -285,28 +285,31 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
|
|||||||
chunker: null,
|
chunker: null,
|
||||||
textChunkLimit: 2000,
|
textChunkLimit: 2000,
|
||||||
pollMaxOptions: 10,
|
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 send = deps?.sendDiscord ?? getDiscordRuntime().channel.discord.sendMessageDiscord;
|
||||||
const result = await send(to, text, {
|
const result = await send(to, text, {
|
||||||
verbose: false,
|
verbose: false,
|
||||||
replyTo: replyToId ?? undefined,
|
replyTo: replyToId ?? undefined,
|
||||||
accountId: accountId ?? undefined,
|
accountId: accountId ?? undefined,
|
||||||
|
silent: silent ?? undefined,
|
||||||
});
|
});
|
||||||
return { channel: "discord", ...result };
|
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 send = deps?.sendDiscord ?? getDiscordRuntime().channel.discord.sendMessageDiscord;
|
||||||
const result = await send(to, text, {
|
const result = await send(to, text, {
|
||||||
verbose: false,
|
verbose: false,
|
||||||
mediaUrl,
|
mediaUrl,
|
||||||
replyTo: replyToId ?? undefined,
|
replyTo: replyToId ?? undefined,
|
||||||
accountId: accountId ?? undefined,
|
accountId: accountId ?? undefined,
|
||||||
|
silent: silent ?? undefined,
|
||||||
});
|
});
|
||||||
return { channel: "discord", ...result };
|
return { channel: "discord", ...result };
|
||||||
},
|
},
|
||||||
sendPoll: async ({ to, poll, accountId }) =>
|
sendPoll: async ({ to, poll, accountId, silent }) =>
|
||||||
await getDiscordRuntime().channel.discord.sendPollDiscord(to, poll, {
|
await getDiscordRuntime().channel.discord.sendPollDiscord(to, poll, {
|
||||||
accountId: accountId ?? undefined,
|
accountId: accountId ?? undefined,
|
||||||
|
silent: silent ?? undefined,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
status: {
|
status: {
|
||||||
|
|||||||
@@ -96,6 +96,7 @@ export const telegramPlugin: ChannelPlugin<ResolvedTelegramAccount, TelegramProb
|
|||||||
reactions: true,
|
reactions: true,
|
||||||
threads: true,
|
threads: true,
|
||||||
media: true,
|
media: true,
|
||||||
|
polls: true,
|
||||||
nativeCommands: true,
|
nativeCommands: true,
|
||||||
blockStreaming: true,
|
blockStreaming: true,
|
||||||
},
|
},
|
||||||
@@ -273,7 +274,8 @@ export const telegramPlugin: ChannelPlugin<ResolvedTelegramAccount, TelegramProb
|
|||||||
chunker: (text, limit) => getTelegramRuntime().channel.text.chunkMarkdownText(text, limit),
|
chunker: (text, limit) => getTelegramRuntime().channel.text.chunkMarkdownText(text, limit),
|
||||||
chunkerMode: "markdown",
|
chunkerMode: "markdown",
|
||||||
textChunkLimit: 4000,
|
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 send = deps?.sendTelegram ?? getTelegramRuntime().channel.telegram.sendMessageTelegram;
|
||||||
const replyToMessageId = parseReplyToMessageId(replyToId);
|
const replyToMessageId = parseReplyToMessageId(replyToId);
|
||||||
const messageThreadId = parseThreadId(threadId);
|
const messageThreadId = parseThreadId(threadId);
|
||||||
@@ -282,10 +284,11 @@ export const telegramPlugin: ChannelPlugin<ResolvedTelegramAccount, TelegramProb
|
|||||||
messageThreadId,
|
messageThreadId,
|
||||||
replyToMessageId,
|
replyToMessageId,
|
||||||
accountId: accountId ?? undefined,
|
accountId: accountId ?? undefined,
|
||||||
|
silent: silent ?? undefined,
|
||||||
});
|
});
|
||||||
return { channel: "telegram", ...result };
|
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 send = deps?.sendTelegram ?? getTelegramRuntime().channel.telegram.sendMessageTelegram;
|
||||||
const replyToMessageId = parseReplyToMessageId(replyToId);
|
const replyToMessageId = parseReplyToMessageId(replyToId);
|
||||||
const messageThreadId = parseThreadId(threadId);
|
const messageThreadId = parseThreadId(threadId);
|
||||||
@@ -295,9 +298,17 @@ export const telegramPlugin: ChannelPlugin<ResolvedTelegramAccount, TelegramProb
|
|||||||
messageThreadId,
|
messageThreadId,
|
||||||
replyToMessageId,
|
replyToMessageId,
|
||||||
accountId: accountId ?? undefined,
|
accountId: accountId ?? undefined,
|
||||||
|
silent: silent ?? undefined,
|
||||||
});
|
});
|
||||||
return { channel: "telegram", ...result };
|
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: {
|
status: {
|
||||||
defaultRuntime: {
|
defaultRuntime: {
|
||||||
|
|||||||
@@ -27,8 +27,9 @@ export const discordOutbound: ChannelOutboundAdapter = {
|
|||||||
});
|
});
|
||||||
return { channel: "discord", ...result };
|
return { channel: "discord", ...result };
|
||||||
},
|
},
|
||||||
sendPoll: async ({ to, poll, accountId }) =>
|
sendPoll: async ({ to, poll, accountId, silent }) =>
|
||||||
await sendPollDiscord(to, poll, {
|
await sendPollDiscord(to, poll, {
|
||||||
accountId: accountId ?? undefined,
|
accountId: accountId ?? undefined,
|
||||||
|
silent: silent ?? undefined,
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -344,4 +344,6 @@ export type ChannelPollContext = {
|
|||||||
poll: PollInput;
|
poll: PollInput;
|
||||||
accountId?: string | null;
|
accountId?: string | null;
|
||||||
threadId?: string | null;
|
threadId?: string | null;
|
||||||
|
silent?: boolean;
|
||||||
|
isAnonymous?: boolean;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -15,8 +15,17 @@ export function registerMessagePollCommand(message: Command, helpers: MessageCli
|
|||||||
[] as string[],
|
[] as string[],
|
||||||
)
|
)
|
||||||
.option("--poll-multi", "Allow multiple selections", false)
|
.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("-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) => {
|
.action(async (opts) => {
|
||||||
await helpers.runMessageAction("poll", opts);
|
await helpers.runMessageAction("poll", opts);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -274,12 +274,15 @@ export async function sendPollDiscord(
|
|||||||
const { channelId } = await resolveChannelId(rest, recipient, request);
|
const { channelId } = await resolveChannelId(rest, recipient, request);
|
||||||
const content = opts.content?.trim();
|
const content = opts.content?.trim();
|
||||||
const payload = normalizeDiscordPollInput(poll);
|
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(
|
const res = (await request(
|
||||||
() =>
|
() =>
|
||||||
rest.post(Routes.channelMessages(channelId), {
|
rest.post(Routes.channelMessages(channelId), {
|
||||||
body: {
|
body: {
|
||||||
content: content || undefined,
|
content: content || undefined,
|
||||||
poll: payload,
|
poll: payload,
|
||||||
|
...(flags ? { flags } : {}),
|
||||||
},
|
},
|
||||||
}) as Promise<{ id: string; channel_id: string }>,
|
}) as Promise<{ id: string; channel_id: string }>,
|
||||||
"poll",
|
"poll",
|
||||||
|
|||||||
@@ -35,7 +35,15 @@ export const PollParamsSchema = Type.Object(
|
|||||||
question: NonEmptyString,
|
question: NonEmptyString,
|
||||||
options: Type.Array(NonEmptyString, { minItems: 2, maxItems: 12 }),
|
options: Type.Array(NonEmptyString, { minItems: 2, maxItems: 12 }),
|
||||||
maxSelections: Type.Optional(Type.Integer({ minimum: 1, maximum: 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 })),
|
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()),
|
channel: Type.Optional(Type.String()),
|
||||||
accountId: Type.Optional(Type.String()),
|
accountId: Type.Optional(Type.String()),
|
||||||
idempotencyKey: NonEmptyString,
|
idempotencyKey: NonEmptyString,
|
||||||
|
|||||||
@@ -274,7 +274,11 @@ export const sendHandlers: GatewayRequestHandlers = {
|
|||||||
question: string;
|
question: string;
|
||||||
options: string[];
|
options: string[];
|
||||||
maxSelections?: number;
|
maxSelections?: number;
|
||||||
|
durationSeconds?: number;
|
||||||
durationHours?: number;
|
durationHours?: number;
|
||||||
|
silent?: boolean;
|
||||||
|
isAnonymous?: boolean;
|
||||||
|
threadId?: string;
|
||||||
channel?: string;
|
channel?: string;
|
||||||
accountId?: string;
|
accountId?: string;
|
||||||
idempotencyKey: string;
|
idempotencyKey: string;
|
||||||
@@ -303,8 +307,13 @@ export const sendHandlers: GatewayRequestHandlers = {
|
|||||||
question: request.question,
|
question: request.question,
|
||||||
options: request.options,
|
options: request.options,
|
||||||
maxSelections: request.maxSelections,
|
maxSelections: request.maxSelections,
|
||||||
|
durationSeconds: request.durationSeconds,
|
||||||
durationHours: request.durationHours,
|
durationHours: request.durationHours,
|
||||||
};
|
};
|
||||||
|
const threadId =
|
||||||
|
typeof request.threadId === "string" && request.threadId.trim().length
|
||||||
|
? request.threadId.trim()
|
||||||
|
: undefined;
|
||||||
const accountId =
|
const accountId =
|
||||||
typeof request.accountId === "string" && request.accountId.trim().length
|
typeof request.accountId === "string" && request.accountId.trim().length
|
||||||
? request.accountId.trim()
|
? request.accountId.trim()
|
||||||
@@ -340,6 +349,9 @@ export const sendHandlers: GatewayRequestHandlers = {
|
|||||||
to: resolved.to,
|
to: resolved.to,
|
||||||
poll: normalized,
|
poll: normalized,
|
||||||
accountId,
|
accountId,
|
||||||
|
threadId,
|
||||||
|
silent: request.silent,
|
||||||
|
isAnonymous: request.isAnonymous,
|
||||||
});
|
});
|
||||||
const payload: Record<string, unknown> = {
|
const payload: Record<string, unknown> = {
|
||||||
runId: idem,
|
runId: idem,
|
||||||
|
|||||||
@@ -568,11 +568,36 @@ async function handlePollAction(ctx: ResolvedActionContext): Promise<MessageActi
|
|||||||
if (options.length < 2) {
|
if (options.length < 2) {
|
||||||
throw new Error("pollOption requires at least two values");
|
throw new Error("pollOption requires at least two values");
|
||||||
}
|
}
|
||||||
|
const silent = readBooleanParam(params, "silent");
|
||||||
const allowMultiselect = readBooleanParam(params, "pollMulti") ?? false;
|
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", {
|
const durationHours = readNumberParam(params, "pollDurationHours", {
|
||||||
integer: true,
|
integer: true,
|
||||||
});
|
});
|
||||||
|
const durationSeconds = readNumberParam(params, "pollDurationSeconds", {
|
||||||
|
integer: true,
|
||||||
|
});
|
||||||
const maxSelections = allowMultiselect ? Math.max(2, options.length) : 1;
|
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 : "";
|
const base = typeof params.message === "string" ? params.message : "";
|
||||||
await maybeApplyCrossContextMarker({
|
await maybeApplyCrossContextMarker({
|
||||||
cfg,
|
cfg,
|
||||||
@@ -595,12 +620,16 @@ async function handlePollAction(ctx: ResolvedActionContext): Promise<MessageActi
|
|||||||
gateway,
|
gateway,
|
||||||
toolContext: input.toolContext,
|
toolContext: input.toolContext,
|
||||||
dryRun,
|
dryRun,
|
||||||
|
silent: silent ?? undefined,
|
||||||
},
|
},
|
||||||
to,
|
to,
|
||||||
question,
|
question,
|
||||||
options,
|
options,
|
||||||
maxSelections,
|
maxSelections,
|
||||||
|
durationSeconds: durationSeconds ?? undefined,
|
||||||
durationHours: durationHours ?? undefined,
|
durationHours: durationHours ?? undefined,
|
||||||
|
threadId: resolvedThreadId ?? undefined,
|
||||||
|
isAnonymous,
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -69,8 +69,13 @@ type MessagePollParams = {
|
|||||||
question: string;
|
question: string;
|
||||||
options: string[];
|
options: string[];
|
||||||
maxSelections?: number;
|
maxSelections?: number;
|
||||||
|
durationSeconds?: number;
|
||||||
durationHours?: number;
|
durationHours?: number;
|
||||||
channel?: string;
|
channel?: string;
|
||||||
|
accountId?: string;
|
||||||
|
threadId?: string;
|
||||||
|
silent?: boolean;
|
||||||
|
isAnonymous?: boolean;
|
||||||
dryRun?: boolean;
|
dryRun?: boolean;
|
||||||
cfg?: OpenClawConfig;
|
cfg?: OpenClawConfig;
|
||||||
gateway?: MessageGatewayOptions;
|
gateway?: MessageGatewayOptions;
|
||||||
@@ -83,6 +88,7 @@ export type MessagePollResult = {
|
|||||||
question: string;
|
question: string;
|
||||||
options: string[];
|
options: string[];
|
||||||
maxSelections: number;
|
maxSelections: number;
|
||||||
|
durationSeconds: number | null;
|
||||||
durationHours: number | null;
|
durationHours: number | null;
|
||||||
via: "gateway";
|
via: "gateway";
|
||||||
result?: {
|
result?: {
|
||||||
@@ -239,6 +245,7 @@ export async function sendPoll(params: MessagePollParams): Promise<MessagePollRe
|
|||||||
question: params.question,
|
question: params.question,
|
||||||
options: params.options,
|
options: params.options,
|
||||||
maxSelections: params.maxSelections,
|
maxSelections: params.maxSelections,
|
||||||
|
durationSeconds: params.durationSeconds,
|
||||||
durationHours: params.durationHours,
|
durationHours: params.durationHours,
|
||||||
};
|
};
|
||||||
const plugin = getChannelPlugin(channel);
|
const plugin = getChannelPlugin(channel);
|
||||||
@@ -257,6 +264,7 @@ export async function sendPoll(params: MessagePollParams): Promise<MessagePollRe
|
|||||||
question: normalized.question,
|
question: normalized.question,
|
||||||
options: normalized.options,
|
options: normalized.options,
|
||||||
maxSelections: normalized.maxSelections,
|
maxSelections: normalized.maxSelections,
|
||||||
|
durationSeconds: normalized.durationSeconds ?? null,
|
||||||
durationHours: normalized.durationHours ?? null,
|
durationHours: normalized.durationHours ?? null,
|
||||||
via: "gateway",
|
via: "gateway",
|
||||||
dryRun: true,
|
dryRun: true,
|
||||||
@@ -279,8 +287,13 @@ export async function sendPoll(params: MessagePollParams): Promise<MessagePollRe
|
|||||||
question: normalized.question,
|
question: normalized.question,
|
||||||
options: normalized.options,
|
options: normalized.options,
|
||||||
maxSelections: normalized.maxSelections,
|
maxSelections: normalized.maxSelections,
|
||||||
|
durationSeconds: normalized.durationSeconds,
|
||||||
durationHours: normalized.durationHours,
|
durationHours: normalized.durationHours,
|
||||||
|
threadId: params.threadId,
|
||||||
|
silent: params.silent,
|
||||||
|
isAnonymous: params.isAnonymous,
|
||||||
channel,
|
channel,
|
||||||
|
accountId: params.accountId,
|
||||||
idempotencyKey: params.idempotencyKey ?? randomIdempotencyKey(),
|
idempotencyKey: params.idempotencyKey ?? randomIdempotencyKey(),
|
||||||
},
|
},
|
||||||
timeoutMs: gateway.timeoutMs,
|
timeoutMs: gateway.timeoutMs,
|
||||||
@@ -295,6 +308,7 @@ export async function sendPoll(params: MessagePollParams): Promise<MessagePollRe
|
|||||||
question: normalized.question,
|
question: normalized.question,
|
||||||
options: normalized.options,
|
options: normalized.options,
|
||||||
maxSelections: normalized.maxSelections,
|
maxSelections: normalized.maxSelections,
|
||||||
|
durationSeconds: normalized.durationSeconds ?? null,
|
||||||
durationHours: normalized.durationHours ?? null,
|
durationHours: normalized.durationHours ?? null,
|
||||||
via: "gateway",
|
via: "gateway",
|
||||||
result,
|
result,
|
||||||
|
|||||||
@@ -145,7 +145,10 @@ export async function executePollAction(params: {
|
|||||||
question: string;
|
question: string;
|
||||||
options: string[];
|
options: string[];
|
||||||
maxSelections: number;
|
maxSelections: number;
|
||||||
|
durationSeconds?: number;
|
||||||
durationHours?: number;
|
durationHours?: number;
|
||||||
|
threadId?: string;
|
||||||
|
isAnonymous?: boolean;
|
||||||
}): Promise<{
|
}): Promise<{
|
||||||
handledBy: "plugin" | "core";
|
handledBy: "plugin" | "core";
|
||||||
payload: unknown;
|
payload: unknown;
|
||||||
@@ -178,8 +181,13 @@ export async function executePollAction(params: {
|
|||||||
question: params.question,
|
question: params.question,
|
||||||
options: params.options,
|
options: params.options,
|
||||||
maxSelections: params.maxSelections,
|
maxSelections: params.maxSelections,
|
||||||
|
durationSeconds: params.durationSeconds ?? undefined,
|
||||||
durationHours: params.durationHours ?? undefined,
|
durationHours: params.durationHours ?? undefined,
|
||||||
channel: params.ctx.channel,
|
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,
|
dryRun: params.ctx.dryRun,
|
||||||
gateway: params.ctx.gateway,
|
gateway: params.ctx.gateway,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -126,7 +126,7 @@ import {
|
|||||||
} from "../../telegram/audit.js";
|
} from "../../telegram/audit.js";
|
||||||
import { monitorTelegramProvider } from "../../telegram/monitor.js";
|
import { monitorTelegramProvider } from "../../telegram/monitor.js";
|
||||||
import { probeTelegram } from "../../telegram/probe.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 { resolveTelegramToken } from "../../telegram/token.js";
|
||||||
import { textToSpeechTelephony } from "../../tts/tts.js";
|
import { textToSpeechTelephony } from "../../tts/tts.js";
|
||||||
import { getActiveWebListener } from "../../web/active-listener.js";
|
import { getActiveWebListener } from "../../web/active-listener.js";
|
||||||
@@ -363,6 +363,7 @@ export function createPluginRuntime(): PluginRuntime {
|
|||||||
probeTelegram,
|
probeTelegram,
|
||||||
resolveTelegramToken,
|
resolveTelegramToken,
|
||||||
sendMessageTelegram,
|
sendMessageTelegram,
|
||||||
|
sendPollTelegram,
|
||||||
monitorTelegramProvider,
|
monitorTelegramProvider,
|
||||||
messageActions: telegramMessageActions,
|
messageActions: telegramMessageActions,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -120,6 +120,7 @@ type CollectTelegramUnmentionedGroupIds =
|
|||||||
type ProbeTelegram = typeof import("../../telegram/probe.js").probeTelegram;
|
type ProbeTelegram = typeof import("../../telegram/probe.js").probeTelegram;
|
||||||
type ResolveTelegramToken = typeof import("../../telegram/token.js").resolveTelegramToken;
|
type ResolveTelegramToken = typeof import("../../telegram/token.js").resolveTelegramToken;
|
||||||
type SendMessageTelegram = typeof import("../../telegram/send.js").sendMessageTelegram;
|
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 MonitorTelegramProvider = typeof import("../../telegram/monitor.js").monitorTelegramProvider;
|
||||||
type TelegramMessageActions =
|
type TelegramMessageActions =
|
||||||
typeof import("../../channels/plugins/actions/telegram.js").telegramMessageActions;
|
typeof import("../../channels/plugins/actions/telegram.js").telegramMessageActions;
|
||||||
@@ -301,6 +302,7 @@ export type PluginRuntime = {
|
|||||||
probeTelegram: ProbeTelegram;
|
probeTelegram: ProbeTelegram;
|
||||||
resolveTelegramToken: ResolveTelegramToken;
|
resolveTelegramToken: ResolveTelegramToken;
|
||||||
sendMessageTelegram: SendMessageTelegram;
|
sendMessageTelegram: SendMessageTelegram;
|
||||||
|
sendPollTelegram: SendPollTelegram;
|
||||||
monitorTelegramProvider: MonitorTelegramProvider;
|
monitorTelegramProvider: MonitorTelegramProvider;
|
||||||
messageActions: TelegramMessageActions;
|
messageActions: TelegramMessageActions;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ describe("polls", () => {
|
|||||||
question: "Lunch?",
|
question: "Lunch?",
|
||||||
options: ["Pizza", "Sushi"],
|
options: ["Pizza", "Sushi"],
|
||||||
maxSelections: 2,
|
maxSelections: 2,
|
||||||
|
durationSeconds: undefined,
|
||||||
durationHours: undefined,
|
durationHours: undefined,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
21
src/polls.ts
21
src/polls.ts
@@ -2,6 +2,15 @@ export type PollInput = {
|
|||||||
question: string;
|
question: string;
|
||||||
options: string[];
|
options: string[];
|
||||||
maxSelections?: number;
|
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;
|
durationHours?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -9,6 +18,7 @@ export type NormalizedPollInput = {
|
|||||||
question: string;
|
question: string;
|
||||||
options: string[];
|
options: string[];
|
||||||
maxSelections: number;
|
maxSelections: number;
|
||||||
|
durationSeconds?: number;
|
||||||
durationHours?: number;
|
durationHours?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -43,6 +53,16 @@ export function normalizePollInput(
|
|||||||
if (maxSelections > cleaned.length) {
|
if (maxSelections > cleaned.length) {
|
||||||
throw new Error("maxSelections cannot exceed option count");
|
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 durationRaw = input.durationHours;
|
||||||
const durationHours =
|
const durationHours =
|
||||||
typeof durationRaw === "number" && Number.isFinite(durationRaw)
|
typeof durationRaw === "number" && Number.isFinite(durationRaw)
|
||||||
@@ -55,6 +75,7 @@ export function normalizePollInput(
|
|||||||
question,
|
question,
|
||||||
options: cleaned,
|
options: cleaned,
|
||||||
maxSelections,
|
maxSelections,
|
||||||
|
durationSeconds,
|
||||||
durationHours,
|
durationHours,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
export { createTelegramBot, createTelegramWebhookCallback } from "./bot.js";
|
export { createTelegramBot, createTelegramWebhookCallback } from "./bot.js";
|
||||||
export { monitorTelegramProvider } from "./monitor.js";
|
export { monitorTelegramProvider } from "./monitor.js";
|
||||||
export { reactMessageTelegram, sendMessageTelegram } from "./send.js";
|
export { reactMessageTelegram, sendMessageTelegram, sendPollTelegram } from "./send.js";
|
||||||
export { startTelegramWebhook } from "./webhook.js";
|
export { startTelegramWebhook } from "./webhook.js";
|
||||||
|
|||||||
63
src/telegram/send.poll.test.ts
Normal file
63
src/telegram/send.poll.test.ts
Normal file
@@ -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 { createSubsystemLogger } from "../logging/subsystem.js";
|
||||||
import { mediaKindFromMime } from "../media/constants.js";
|
import { mediaKindFromMime } from "../media/constants.js";
|
||||||
import { isGifMedia } from "../media/mime.js";
|
import { isGifMedia } from "../media/mime.js";
|
||||||
|
import { normalizePollInput, type PollInput } from "../polls.js";
|
||||||
import { loadWebMedia } from "../web/media.js";
|
import { loadWebMedia } from "../web/media.js";
|
||||||
import { type ResolvedTelegramAccount, resolveTelegramAccount } from "./accounts.js";
|
import { type ResolvedTelegramAccount, resolveTelegramAccount } from "./accounts.js";
|
||||||
import { withTelegramApiErrorLogging } from "./api-logging.js";
|
import { withTelegramApiErrorLogging } from "./api-logging.js";
|
||||||
@@ -923,3 +924,154 @@ export async function sendStickerTelegram(
|
|||||||
|
|
||||||
return { messageId, chatId: resolvedChatId };
|
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?",
|
question: "Lunch?",
|
||||||
options: ["Pizza", "Sushi"],
|
options: ["Pizza", "Sushi"],
|
||||||
maxSelections: 2,
|
maxSelections: 2,
|
||||||
|
durationSeconds: undefined,
|
||||||
durationHours: undefined,
|
durationHours: undefined,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user