diff --git a/docs/channels/mattermost.md b/docs/channels/mattermost.md index f4353180e2a..052a8cd6b12 100644 --- a/docs/channels/mattermost.md +++ b/docs/channels/mattermost.md @@ -70,6 +70,7 @@ Mattermost responds to DMs automatically. Channel behavior is controlled by `cha - `oncall` (default): respond only when @mentioned in channels. - `onmessage`: respond to every channel message. +- `always`: respond to every message in channels (same channel behavior as `onmessage`). - `onchar`: respond when a message starts with a trigger prefix. Config example: @@ -89,6 +90,25 @@ Notes: - `onchar` still responds to explicit @mentions. - `channels.mattermost.requireMention` is honored for legacy configs but `chatmode` is preferred. +- Current limitation: due to Mattermost plugin event behavior (`#11797`), `chatmode: "onmessage"` and + `chatmode: "always"` may still require explicit group mention override to respond without @mentions. + Use: + +```json5 +{ + channels: { + mattermost: { + groupPolicy: "open", + groups: { + "*": { requireMention: false }, + }, + }, + }, +} +``` + +Reference: [Bug: Mattermost plugin does not receive channel message events via WebSocket #11797](https://github.com/open-webui/open-webui/issues/11797). +Related fix scope: [fix(mattermost): honor chatmode mention fallback in group mention gating #14995](https://github.com/open-webui/open-webui/pull/14995). ## Access control (DMs) @@ -133,6 +153,7 @@ Mattermost supports multiple accounts under `channels.mattermost.accounts`: ## Troubleshooting -- No replies in channels: ensure the bot is in the channel and mention it (oncall), use a trigger prefix (onchar), or set `chatmode: "onmessage"`. +- No replies in channels: ensure the bot is in the channel and use the mode behavior correctly: mention it (`oncall`), use a trigger prefix (`onchar`), or use `onmessage`/`always` with: + `channels.mattermost.groups["*"].requireMention = false` (and typically `groupPolicy: "open"`). - Auth errors: check the bot token, base URL, and whether the account is enabled. - Multi-account issues: env vars only apply to the `default` account. diff --git a/src/telegram/allowed-updates.test.ts b/src/telegram/allowed-updates.test.ts new file mode 100644 index 00000000000..86e0b5224a4 --- /dev/null +++ b/src/telegram/allowed-updates.test.ts @@ -0,0 +1,9 @@ +import { describe, expect, it } from "vitest"; +import { resolveTelegramAllowedUpdates } from "./allowed-updates.js"; + +describe("resolveTelegramAllowedUpdates", () => { + it("includes poll_answer updates", () => { + const updates = resolveTelegramAllowedUpdates(); + expect(updates).toContain("poll_answer"); + }); +}); diff --git a/src/telegram/allowed-updates.ts b/src/telegram/allowed-updates.ts index e32fefd096f..7dfbb7a8258 100644 --- a/src/telegram/allowed-updates.ts +++ b/src/telegram/allowed-updates.ts @@ -4,6 +4,9 @@ type TelegramUpdateType = (typeof API_CONSTANTS.ALL_UPDATE_TYPES)[number]; export function resolveTelegramAllowedUpdates(): ReadonlyArray { const updates = [...API_CONSTANTS.DEFAULT_UPDATE_TYPES] as TelegramUpdateType[]; + if (!updates.includes("poll_answer")) { + updates.push("poll_answer"); + } if (!updates.includes("message_reaction")) { updates.push("message_reaction"); } diff --git a/src/telegram/bot-handlers.ts b/src/telegram/bot-handlers.ts index bd1a7bfa4bd..ee98a218fc6 100644 --- a/src/telegram/bot-handlers.ts +++ b/src/telegram/bot-handlers.ts @@ -50,6 +50,7 @@ import { parseModelCallbackData, type ProviderInfo, } from "./model-buttons.js"; +import { getSentPoll } from "./poll-vote-cache.js"; import { buildInlineKeyboard } from "./send.js"; export const registerTelegramHandlers = ({ @@ -749,6 +750,65 @@ export const registerTelegramHandlers = ({ } }); + bot.on("poll_answer", async (ctx) => { + try { + if (shouldSkipUpdate(ctx)) { + return; + } + const pollAnswer = (ctx.update as { poll_answer?: unknown })?.poll_answer as + | { + poll_id?: string; + user?: { id?: number; username?: string; first_name?: string }; + option_ids?: number[]; + } + | undefined; + if (!pollAnswer) { + return; + } + const pollId = pollAnswer?.poll_id?.trim(); + if (!pollId) { + return; + } + const pollMeta = getSentPoll(pollId); + if (!pollMeta) { + return; + } + if (pollMeta.accountId && pollMeta.accountId !== accountId) { + return; + } + const userId = pollAnswer.user?.id; + if (typeof userId !== "number") { + return; + } + const optionIds = Array.isArray(pollAnswer.option_ids) ? pollAnswer.option_ids : []; + const selected = optionIds.map((id) => pollMeta.options[id] ?? `option#${id + 1}`); + const selectedText = selected.length > 0 ? selected.join(", ") : "(cleared vote)"; + const syntheticText = `Poll vote update: "${pollMeta.question}" -> ${selectedText}`; + const syntheticMessage = { + message_id: Date.now(), + date: Math.floor(Date.now() / 1000), + chat: { + id: Number(pollMeta.chatId), + type: String(pollMeta.chatId).startsWith("-") ? "supergroup" : "private", + }, + from: { + id: userId, + is_bot: false, + first_name: pollAnswer.user?.first_name ?? "User", + username: pollAnswer.user?.username, + }, + text: syntheticText, + } as unknown as Message; + const storeAllowFrom = await loadStoreAllowFrom(); + await processMessage(buildSyntheticContext(ctx, syntheticMessage), [], storeAllowFrom, { + forceWasMentioned: true, + messageIdOverride: `poll:${pollId}:${userId}:${Date.now()}`, + }); + } catch (err) { + runtime.error?.(danger(`poll_answer handler failed: ${String(err)}`)); + } + }); + // Handle group migration to supergroup (chat ID changes) bot.on("message:migrate_to_chat_id", async (ctx) => { try { diff --git a/src/telegram/poll-vote-cache.ts b/src/telegram/poll-vote-cache.ts new file mode 100644 index 00000000000..c65759985f3 --- /dev/null +++ b/src/telegram/poll-vote-cache.ts @@ -0,0 +1,35 @@ +const TTL_MS = 24 * 60 * 60 * 1000; + +export type TelegramSentPoll = { + pollId: string; + chatId: string; + question: string; + options: string[]; + accountId?: string; + createdAt: number; +}; + +const pollById = new Map(); + +function cleanupExpired() { + const now = Date.now(); + for (const [pollId, poll] of pollById) { + if (now - poll.createdAt > TTL_MS) { + pollById.delete(pollId); + } + } +} + +export function recordSentPoll(poll: Omit) { + cleanupExpired(); + pollById.set(poll.pollId, { ...poll, createdAt: Date.now() }); +} + +export function getSentPoll(pollId: string): TelegramSentPoll | undefined { + cleanupExpired(); + return pollById.get(pollId); +} + +export function clearSentPollCache() { + pollById.clear(); +} diff --git a/src/telegram/send.ts b/src/telegram/send.ts index 98721b5ad08..6b20c4a6593 100644 --- a/src/telegram/send.ts +++ b/src/telegram/send.ts @@ -27,6 +27,7 @@ import { splitTelegramCaption } from "./caption.js"; import { resolveTelegramFetch } from "./fetch.js"; import { renderTelegramHtmlText } from "./format.js"; import { isRecoverableTelegramNetworkError } from "./network-errors.js"; +import { recordSentPoll } from "./poll-vote-cache.js"; import { makeProxyFetch } from "./proxy.js"; import { recordSentMessage } from "./sent-message-cache.js"; import { parseTelegramTarget, stripTelegramInternalPrefixes } from "./targets.js"; @@ -1055,6 +1056,15 @@ export async function sendPollTelegram( if (result?.message_id) { recordSentMessage(chatId, result.message_id); } + if (pollId) { + recordSentPoll({ + pollId, + chatId: resolvedChatId, + question: normalizedPoll.question, + options: normalizedPoll.options, + accountId: account.accountId, + }); + } recordChannelActivity({ channel: "telegram",