From c964ca3826a667cb7c9962e6e18bb132170e3a1b Mon Sep 17 00:00:00 2001 From: Cameron Date: Thu, 5 Mar 2026 11:20:57 -0800 Subject: [PATCH] refactor: extract shared channel utilities (access control, emoji, message splitting) (#492) --- src/channels/discord.ts | 111 ++---------------- src/channels/shared/access-control.ts | 46 ++++++++ src/channels/shared/emoji.ts | 33 ++++++ src/channels/shared/message-splitter.ts | 90 +++++++++++++++ src/channels/signal.ts | 27 +---- src/channels/slack.ts | 20 +--- src/channels/telegram-mtproto.ts | 26 +---- src/channels/telegram.ts | 146 ++---------------------- 8 files changed, 198 insertions(+), 301 deletions(-) create mode 100644 src/channels/shared/access-control.ts create mode 100644 src/channels/shared/emoji.ts create mode 100644 src/channels/shared/message-splitter.ts diff --git a/src/channels/discord.ts b/src/channels/discord.ts index 9e15494..8b137cf 100644 --- a/src/channels/discord.ts +++ b/src/channels/discord.ts @@ -8,7 +8,10 @@ import type { ChannelAdapter } from './types.js'; import type { InboundAttachment, InboundMessage, InboundReaction, OutboundFile, OutboundMessage } from '../core/types.js'; import type { DmPolicy } from '../pairing/types.js'; -import { isUserAllowed, upsertPairingRequest } from '../pairing/store.js'; +import { upsertPairingRequest } from '../pairing/store.js'; +import { checkDmAccess } from './shared/access-control.js'; +import { resolveEmoji } from './shared/emoji.js'; +import { splitMessageText } from './shared/message-splitter.js'; import { buildAttachmentPath, downloadToFile } from './attachments.js'; import { HELP_TEXT } from '../core/commands.js'; import { isGroupAllowed, isGroupUserAllowed, resolveGroupMode, resolveReceiveBotMessages, resolveDailyLimits, checkDailyLimit, type GroupModeConfig } from './group-mode.js'; @@ -69,31 +72,8 @@ export class DiscordAdapter implements ChannelAdapter { this.attachmentsMaxBytes = config.attachmentsMaxBytes; } - /** - * Check if a user is authorized based on dmPolicy - * Returns 'allowed', 'blocked', or 'pairing' - */ private async checkAccess(userId: string): Promise<'allowed' | 'blocked' | 'pairing'> { - const policy = this.config.dmPolicy || 'pairing'; - - // Open policy: everyone allowed - if (policy === 'open') { - return 'allowed'; - } - - // Check if already allowed (config or store) - const allowed = await isUserAllowed('discord', userId, this.config.allowedUsers); - if (allowed) { - return 'allowed'; - } - - // Allowlist policy: not allowed if not in list - if (policy === 'allowlist') { - return 'blocked'; - } - - // Pairing policy: needs pairing - return 'pairing'; + return checkDmAccess('discord', userId, this.config.dmPolicy, this.config.allowedUsers); } /** @@ -362,7 +342,7 @@ Ask the bot owner to approve with: } const sendable = channel as { send: (content: string) => Promise<{ id: string }> }; - const chunks = splitMessageText(msg.text); + const chunks = splitMessageText(msg.text, DISCORD_SPLIT_THRESHOLD); let lastMessageId = ''; for (const chunk of chunks) { const result = await sendable.send(chunk); @@ -418,7 +398,7 @@ Ask the bot owner to approve with: const textChannel = channel as { messages: { fetch: (id: string) => Promise<{ react: (input: string) => Promise }> } }; const message = await textChannel.messages.fetch(messageId); - const resolved = resolveDiscordEmoji(emoji); + const resolved = resolveEmoji(emoji); await message.react(resolved); } @@ -554,85 +534,10 @@ Ask the bot owner to approve with: } } -const DISCORD_EMOJI_ALIAS_TO_UNICODE: Record = { - eyes: '\u{1F440}', - thumbsup: '\u{1F44D}', - thumbs_up: '\u{1F44D}', - '+1': '\u{1F44D}', - heart: '\u2764\uFE0F', - fire: '\u{1F525}', - smile: '\u{1F604}', - laughing: '\u{1F606}', - tada: '\u{1F389}', - clap: '\u{1F44F}', - ok_hand: '\u{1F44C}', - white_check_mark: '\u2705', -}; - -function resolveDiscordEmoji(input: string): string { - const aliasMatch = input.match(/^:([^:]+):$/); - const alias = aliasMatch ? aliasMatch[1] : null; - if (alias && DISCORD_EMOJI_ALIAS_TO_UNICODE[alias]) { - return DISCORD_EMOJI_ALIAS_TO_UNICODE[alias]; - } - if (DISCORD_EMOJI_ALIAS_TO_UNICODE[input]) { - return DISCORD_EMOJI_ALIAS_TO_UNICODE[input]; - } - return input; -} - -// Discord message length limit +// Discord message length limits const DISCORD_MAX_LENGTH = 2000; -// Leave some headroom when choosing split points const DISCORD_SPLIT_THRESHOLD = 1900; -/** - * Split text into chunks that fit within Discord's 2000-char limit. - * Splits at paragraph boundaries (double newlines), falling back to - * single newlines, then hard-splitting at the threshold. - */ -function splitMessageText(text: string): string[] { - if (text.length <= DISCORD_SPLIT_THRESHOLD) { - return [text]; - } - - const chunks: string[] = []; - let remaining = text; - - while (remaining.length > DISCORD_SPLIT_THRESHOLD) { - let splitIdx = -1; - - const searchRegion = remaining.slice(0, DISCORD_SPLIT_THRESHOLD); - // Try paragraph boundary (double newline) - const lastParagraph = searchRegion.lastIndexOf('\n\n'); - if (lastParagraph > DISCORD_SPLIT_THRESHOLD * 0.3) { - splitIdx = lastParagraph; - } - - // Fall back to single newline - if (splitIdx === -1) { - const lastNewline = searchRegion.lastIndexOf('\n'); - if (lastNewline > DISCORD_SPLIT_THRESHOLD * 0.3) { - splitIdx = lastNewline; - } - } - - // Hard split as last resort - if (splitIdx === -1) { - splitIdx = DISCORD_SPLIT_THRESHOLD; - } - - chunks.push(remaining.slice(0, splitIdx).trimEnd()); - remaining = remaining.slice(splitIdx).trimStart(); - } - - if (remaining.trim()) { - chunks.push(remaining.trim()); - } - - return chunks; -} - type DiscordAttachment = { id?: string; name?: string | null; diff --git a/src/channels/shared/access-control.ts b/src/channels/shared/access-control.ts new file mode 100644 index 0000000..6e401b9 --- /dev/null +++ b/src/channels/shared/access-control.ts @@ -0,0 +1,46 @@ +/** + * Shared DM access control logic for channel adapters. + * + * Centralizes the dmPolicy enforcement that was previously + * copy-pasted across Discord, Telegram, Signal, etc. + */ + +import type { DmPolicy } from '../../pairing/types.js'; +import { isUserAllowed } from '../../pairing/store.js'; + +/** + * Check if a user is authorized based on dmPolicy. + * + * @param channel - Channel name (e.g. 'discord', 'telegram', 'signal') + * @param userId - User identifier (channel-specific format) + * @param policy - DM policy from config (defaults to 'pairing') + * @param allowedUsers - Config-level allowlist + * @returns 'allowed' | 'blocked' | 'pairing' + */ +export async function checkDmAccess( + channel: string, + userId: string, + policy: DmPolicy | undefined, + allowedUsers?: string[], +): Promise<'allowed' | 'blocked' | 'pairing'> { + const effectivePolicy = policy || 'pairing'; + + // Open policy: everyone allowed + if (effectivePolicy === 'open') { + return 'allowed'; + } + + // Check if already allowed (config or pairing store) + const allowed = await isUserAllowed(channel, userId, allowedUsers); + if (allowed) { + return 'allowed'; + } + + // Allowlist policy: not allowed if not in list + if (effectivePolicy === 'allowlist') { + return 'blocked'; + } + + // Pairing policy: needs pairing + return 'pairing'; +} diff --git a/src/channels/shared/emoji.ts b/src/channels/shared/emoji.ts new file mode 100644 index 0000000..1546ce2 --- /dev/null +++ b/src/channels/shared/emoji.ts @@ -0,0 +1,33 @@ +/** + * Shared emoji alias table and resolver for channel adapters. + * + * All channels use the same alias-to-unicode mappings; only the + * resolver behavior differs slightly (Slack needs reverse lookup). + */ + +export const EMOJI_ALIASES: Record = { + eyes: '\u{1F440}', + thumbsup: '\u{1F44D}', + thumbs_up: '\u{1F44D}', + '+1': '\u{1F44D}', + heart: '\u2764\uFE0F', + fire: '\u{1F525}', + smile: '\u{1F604}', + laughing: '\u{1F606}', + tada: '\u{1F389}', + clap: '\u{1F44F}', + ok_hand: '\u{1F44C}', + white_check_mark: '\u2705', +}; + +/** + * Resolve an emoji alias (e.g. `:thumbsup:` or `thumbsup`) to its + * unicode character. Returns the input unchanged if no alias matches. + */ +export function resolveEmoji(input: string): string { + const match = input.match(/^:([^:]+):$/); + const alias = match ? match[1] : null; + if (alias && EMOJI_ALIASES[alias]) return EMOJI_ALIASES[alias]; + if (EMOJI_ALIASES[input]) return EMOJI_ALIASES[input]; + return input; +} diff --git a/src/channels/shared/message-splitter.ts b/src/channels/shared/message-splitter.ts new file mode 100644 index 0000000..c633755 --- /dev/null +++ b/src/channels/shared/message-splitter.ts @@ -0,0 +1,90 @@ +/** + * Shared message text splitting for channels with length limits. + * + * Splits at paragraph boundaries (double newlines), falling back + * to single newlines, then hard-splitting at the threshold. + */ + +/** + * Split text into chunks that fit within a channel's character limit. + * + * @param text - Raw text to split + * @param threshold - Soft limit to start splitting at (leave headroom for formatting overhead) + * @returns Array of text chunks + */ +export function splitMessageText(text: string, threshold: number): string[] { + if (text.length <= threshold) { + return [text]; + } + + const chunks: string[] = []; + let remaining = text; + + while (remaining.length > threshold) { + let splitIdx = -1; + + const searchRegion = remaining.slice(0, threshold); + + // Try paragraph boundary (double newline) + const lastParagraph = searchRegion.lastIndexOf('\n\n'); + if (lastParagraph > threshold * 0.3) { + splitIdx = lastParagraph; + } + + // Fall back to single newline + if (splitIdx === -1) { + const lastNewline = searchRegion.lastIndexOf('\n'); + if (lastNewline > threshold * 0.3) { + splitIdx = lastNewline; + } + } + + // Hard split as last resort + if (splitIdx === -1) { + splitIdx = threshold; + } + + chunks.push(remaining.slice(0, splitIdx).trimEnd()); + remaining = remaining.slice(splitIdx).trimStart(); + } + + if (remaining.trim()) { + chunks.push(remaining.trim()); + } + + return chunks; +} + +/** + * Split already-formatted text at an absolute character limit. + * Used as a safety net when formatting expands text beyond the limit. + * + * @param text - Formatted text to split + * @param maxLength - Hard character limit + * @returns Array of text chunks + */ +export function splitFormattedText(text: string, maxLength: number): string[] { + if (text.length <= maxLength) { + return [text]; + } + + const chunks: string[] = []; + let remaining = text; + + while (remaining.length > maxLength) { + const searchRegion = remaining.slice(0, maxLength); + let splitIdx = searchRegion.lastIndexOf('\n'); + if (splitIdx < maxLength * 0.3) { + // No good newline found - hard split + splitIdx = maxLength; + } + chunks.push(remaining.slice(0, splitIdx)); + remaining = remaining.slice(splitIdx).replace(/^\n/, ''); + } + + if (remaining) { + chunks.push(remaining); + } + + return chunks; +} diff --git a/src/channels/signal.ts b/src/channels/signal.ts index 28c8b2b..c2e79e5 100644 --- a/src/channels/signal.ts +++ b/src/channels/signal.ts @@ -11,9 +11,9 @@ import { applySignalGroupGating } from './signal/group-gating.js'; import { resolveDailyLimits, checkDailyLimit } from './group-mode.js'; import type { DmPolicy } from '../pairing/types.js'; import { - isUserAllowed, upsertPairingRequest, } from '../pairing/store.js'; +import { checkDmAccess } from './shared/access-control.js'; import { buildAttachmentPath } from './attachments.js'; import { parseCommand, HELP_TEXT } from '../core/commands.js'; import { spawn, type ChildProcess } from 'node:child_process'; @@ -178,31 +178,8 @@ export class SignalAdapter implements ChannelAdapter { this.baseUrl = `http://${host}:${port}`; } - /** - * Check if a user is authorized based on dmPolicy - * Returns 'allowed', 'blocked', or 'pairing' - */ private async checkAccess(userId: string): Promise<'allowed' | 'blocked' | 'pairing'> { - const policy = this.config.dmPolicy || 'pairing'; - - // Open policy: everyone allowed - if (policy === 'open') { - return 'allowed'; - } - - // Check if already allowed (config or store) - const allowed = await isUserAllowed('signal', userId, this.config.allowedUsers); - if (allowed) { - return 'allowed'; - } - - // Allowlist policy: not allowed if not in list - if (policy === 'allowlist') { - return 'blocked'; - } - - // Pairing policy: needs pairing - return 'pairing'; + return checkDmAccess('signal', userId, this.config.dmPolicy, this.config.allowedUsers); } /** diff --git a/src/channels/slack.ts b/src/channels/slack.ts index d0e7adc..0ef3ad9 100644 --- a/src/channels/slack.ts +++ b/src/channels/slack.ts @@ -13,6 +13,7 @@ import { parseCommand, HELP_TEXT } from '../core/commands.js'; import { markdownToSlackMrkdwn } from './slack-format.js'; import { isGroupAllowed, isGroupUserAllowed, resolveGroupMode, resolveDailyLimits, checkDailyLimit, type GroupMode, type GroupModeConfig } from './group-mode.js'; +import { EMOJI_ALIASES } from './shared/emoji.js'; import { createLogger } from '../logger.js'; const log = createLogger('Slack'); @@ -535,22 +536,9 @@ async function collectSlackAttachments( return attachments; } -const EMOJI_ALIAS_TO_UNICODE: Record = { - eyes: '👀', - thumbsup: '👍', - thumbs_up: '👍', - '+1': '👍', - heart: '❤️', - fire: '🔥', - smile: '😄', - laughing: '😆', - tada: '🎉', - clap: '👏', - ok_hand: '👌', -}; - +// Reverse lookup: unicode -> alias name (for Slack API which uses names, not unicode) const UNICODE_TO_ALIAS = new Map( - Object.entries(EMOJI_ALIAS_TO_UNICODE).map(([name, value]) => [value, name]) + Object.entries(EMOJI_ALIASES).map(([name, value]) => [value, name]) ); function resolveSlackEmojiName(input: string): string | null { @@ -558,7 +546,7 @@ function resolveSlackEmojiName(input: string): string | null { if (aliasMatch) { return aliasMatch[1]; } - if (EMOJI_ALIAS_TO_UNICODE[input]) { + if (EMOJI_ALIASES[input]) { return input; } return UNICODE_TO_ALIAS.get(input) || null; diff --git a/src/channels/telegram-mtproto.ts b/src/channels/telegram-mtproto.ts index dc72cec..9869888 100644 --- a/src/channels/telegram-mtproto.ts +++ b/src/channels/telegram-mtproto.ts @@ -18,7 +18,8 @@ import type { ChannelAdapter } from './types.js'; import type { InboundMessage, OutboundMessage } from '../core/types.js'; import type { DmPolicy } from '../pairing/types.js'; -import { isUserAllowed, upsertPairingRequest, approvePairingCode } from '../pairing/store.js'; +import { upsertPairingRequest, approvePairingCode } from '../pairing/store.js'; +import { checkDmAccess } from './shared/access-control.js'; import { markdownToTdlib } from './telegram-mtproto-format.js'; import * as readline from 'node:readline'; @@ -89,30 +90,13 @@ export class TelegramMTProtoAdapter implements ChannelAdapter { }; } - /** - * Check if a user is authorized based on dmPolicy - */ private async checkAccess(userId: number): Promise<'allowed' | 'blocked' | 'pairing'> { - const policy = this.config.dmPolicy || 'pairing'; - - if (policy === 'open') { - return 'allowed'; - } - - const allowed = await isUserAllowed( + return checkDmAccess( 'telegram-mtproto', String(userId), - this.config.allowedUsers?.map(String) + this.config.dmPolicy, + this.config.allowedUsers?.map(String), ); - if (allowed) { - return 'allowed'; - } - - if (policy === 'allowlist') { - return 'blocked'; - } - - return 'pairing'; } /** diff --git a/src/channels/telegram.ts b/src/channels/telegram.ts index 9857da6..16595f6 100644 --- a/src/channels/telegram.ts +++ b/src/channels/telegram.ts @@ -14,6 +14,9 @@ import { upsertPairingRequest, formatPairingMessage, } from '../pairing/store.js'; +import { checkDmAccess } from './shared/access-control.js'; +import { resolveEmoji } from './shared/emoji.js'; +import { splitMessageText, splitFormattedText } from './shared/message-splitter.js'; import { isGroupApproved, approveGroup } from '../pairing/group-store.js'; import { basename } from 'node:path'; import { buildAttachmentPath, downloadToFile } from './attachments.js'; @@ -110,33 +113,9 @@ export class TelegramAdapter implements ChannelAdapter { return { isGroup, groupName, wasMentioned, isListeningMode }; } - /** - * Check if a user is authorized based on dmPolicy - * Returns true if allowed, false if blocked, 'pairing' if pending pairing - */ - private async checkAccess(userId: string, username?: string, firstName?: string): Promise<'allowed' | 'blocked' | 'pairing'> { - const policy = this.config.dmPolicy || 'pairing'; - const userIdStr = userId; - - // Open policy: everyone allowed - if (policy === 'open') { - return 'allowed'; - } - - // Check if already allowed (config or store) + private async checkAccess(userId: string, _username?: string, _firstName?: string): Promise<'allowed' | 'blocked' | 'pairing'> { const configAllowlist = this.config.allowedUsers?.map(String); - const allowed = await isUserAllowed('telegram', userIdStr, configAllowlist); - if (allowed) { - return 'allowed'; - } - - // Allowlist policy: not allowed if not in list - if (policy === 'allowlist') { - return 'blocked'; - } - - // Pairing policy: create/update pairing request - return 'pairing'; + return checkDmAccess('telegram', userId, this.config.dmPolicy, configAllowlist); } private setupHandlers(): void { @@ -533,7 +512,7 @@ export class TelegramAdapter implements ChannelAdapter { const { markdownToTelegramV2 } = await import('./telegram-format.js'); // Split long messages into chunks (Telegram limit: 4096 chars) - const chunks = splitMessageText(msg.text); + const chunks = splitMessageText(msg.text, TELEGRAM_SPLIT_THRESHOLD); let lastMessageId = ''; for (const chunk of chunks) { @@ -560,7 +539,7 @@ export class TelegramAdapter implements ChannelAdapter { const formatted = await markdownToTelegramV2(chunk); // MarkdownV2 escaping can expand text beyond 4096 - re-split if needed if (formatted.length > TELEGRAM_MAX_LENGTH) { - const subChunks = splitFormattedText(formatted); + const subChunks = splitFormattedText(formatted, TELEGRAM_MAX_LENGTH); for (const sub of subChunks) { const result = await this.bot.api.sendMessage(msg.chatId, sub, { parse_mode: 'MarkdownV2', @@ -578,7 +557,7 @@ export class TelegramAdapter implements ChannelAdapter { } catch (e) { // If MarkdownV2 fails, send raw text (also split if needed) log.warn('MarkdownV2 send failed, falling back to raw text:', e); - const plainChunks = splitFormattedText(chunk); + const plainChunks = splitFormattedText(chunk, TELEGRAM_MAX_LENGTH); for (const plain of plainChunks) { const result = await this.bot.api.sendMessage(msg.chatId, plain, { reply_to_message_id: replyId, @@ -638,7 +617,7 @@ export class TelegramAdapter implements ChannelAdapter { } async addReaction(chatId: string, messageId: string, emoji: string): Promise { - const resolved = resolveTelegramEmoji(emoji); + const resolved = resolveEmoji(emoji); if (!TELEGRAM_REACTION_SET.has(resolved)) { throw new Error(`Unsupported Telegram reaction emoji: ${resolved}`); } @@ -831,32 +810,6 @@ function extractTelegramReaction(reaction?: { return null; } -const TELEGRAM_EMOJI_ALIAS_TO_UNICODE: Record = { - eyes: '👀', - thumbsup: '👍', - thumbs_up: '👍', - '+1': '👍', - heart: '❤️', - fire: '🔥', - smile: '😄', - laughing: '😆', - tada: '🎉', - clap: '👏', - ok_hand: '👌', -}; - -function resolveTelegramEmoji(input: string): string { - const match = input.match(/^:([^:]+):$/); - const alias = match ? match[1] : null; - if (alias && TELEGRAM_EMOJI_ALIAS_TO_UNICODE[alias]) { - return TELEGRAM_EMOJI_ALIAS_TO_UNICODE[alias]; - } - if (TELEGRAM_EMOJI_ALIAS_TO_UNICODE[input]) { - return TELEGRAM_EMOJI_ALIAS_TO_UNICODE[input]; - } - return input; -} - const TELEGRAM_REACTION_EMOJIS = [ '👍', '👎', '❤', '🔥', '🥰', '👏', '😁', '🤔', '🤯', '😱', '🤬', '😢', '🎉', '🤩', '🤮', '💩', '🙏', '👌', '🕊', '🤡', '🥱', '🥴', '😍', '🐳', @@ -871,85 +824,6 @@ type TelegramReactionEmoji = typeof TELEGRAM_REACTION_EMOJIS[number]; const TELEGRAM_REACTION_SET = new Set(TELEGRAM_REACTION_EMOJIS); -// Telegram message length limit +// Telegram message length limits const TELEGRAM_MAX_LENGTH = 4096; -// Leave room for MarkdownV2 escaping overhead when splitting raw text const TELEGRAM_SPLIT_THRESHOLD = 3800; - -/** - * Split raw markdown text into chunks that will fit within Telegram's limit - * after MarkdownV2 formatting. Splits at paragraph boundaries (double newlines), - * falling back to single newlines, then hard-splitting at the threshold. - */ -function splitMessageText(text: string): string[] { - if (text.length <= TELEGRAM_SPLIT_THRESHOLD) { - return [text]; - } - - const chunks: string[] = []; - let remaining = text; - - while (remaining.length > TELEGRAM_SPLIT_THRESHOLD) { - let splitIdx = -1; - - // Try paragraph boundary (double newline) - const searchRegion = remaining.slice(0, TELEGRAM_SPLIT_THRESHOLD); - const lastParagraph = searchRegion.lastIndexOf('\n\n'); - if (lastParagraph > TELEGRAM_SPLIT_THRESHOLD * 0.3) { - splitIdx = lastParagraph; - } - - // Fall back to single newline - if (splitIdx === -1) { - const lastNewline = searchRegion.lastIndexOf('\n'); - if (lastNewline > TELEGRAM_SPLIT_THRESHOLD * 0.3) { - splitIdx = lastNewline; - } - } - - // Hard split as last resort - if (splitIdx === -1) { - splitIdx = TELEGRAM_SPLIT_THRESHOLD; - } - - chunks.push(remaining.slice(0, splitIdx).trimEnd()); - remaining = remaining.slice(splitIdx).trimStart(); - } - - if (remaining.trim()) { - chunks.push(remaining.trim()); - } - - return chunks; -} - -/** - * Split already-formatted text (MarkdownV2 or plain) at the hard 4096 limit. - * Used as a safety net when formatting expands text beyond the limit. - * Tries to split at newlines to avoid breaking mid-word. - */ -function splitFormattedText(text: string): string[] { - if (text.length <= TELEGRAM_MAX_LENGTH) { - return [text]; - } - - const chunks: string[] = []; - let remaining = text; - - while (remaining.length > TELEGRAM_MAX_LENGTH) { - const searchRegion = remaining.slice(0, TELEGRAM_MAX_LENGTH); - let splitIdx = searchRegion.lastIndexOf('\n'); - if (splitIdx < TELEGRAM_MAX_LENGTH * 0.3) { - // No good newline found - hard split - splitIdx = TELEGRAM_MAX_LENGTH; - } - chunks.push(remaining.slice(0, splitIdx)); - remaining = remaining.slice(splitIdx).replace(/^\n/, ''); - } - - if (remaining) { - chunks.push(remaining); - } - - return chunks; -}