refactor: extract shared channel utilities (access control, emoji, message splitting) (#492)
This commit is contained in:
@@ -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;
|
||||
|
||||
46
src/channels/shared/access-control.ts
Normal file
46
src/channels/shared/access-control.ts
Normal 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';
|
||||
}
|
||||
33
src/channels/shared/emoji.ts
Normal file
33
src/channels/shared/emoji.ts
Normal 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;
|
||||
}
|
||||
90
src/channels/shared/message-splitter.ts
Normal file
90
src/channels/shared/message-splitter.ts
Normal 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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user