refactor: extract shared channel utilities (access control, emoji, message splitting) (#492)

This commit is contained in:
Cameron
2026-03-05 11:20:57 -08:00
committed by GitHub
parent 7173d2e2c4
commit c964ca3826
8 changed files with 198 additions and 301 deletions

View File

@@ -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<unknown> }> } };
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<string, string> = {
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;

View File

@@ -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';
}

View File

@@ -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<string, string> = {
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;
}

View File

@@ -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;
}

View File

@@ -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);
}
/**

View File

@@ -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<string, string> = {
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<string, string>(
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;

View File

@@ -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';
}
/**

View File

@@ -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<void> {
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<string, string> = {
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<string>(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;
}