@@ -1,4 +1,4 @@
import { Composer , InlineKeyboard } from "grammy" ;
import { Composer , InlineKeyboard , type Context } from "grammy" ;
import { formatUtc } from "../../libs/time.js" ;
import type { ChatHistoryService } from "../../services/chat-history-service.js" ;
@@ -8,21 +8,22 @@ const PREVIEW_LENGTH = 150;
const BUTTONS_PER_ROW = 5 ;
/** Страница «самые свежие» — в callback_data кодируем нулём . */
const LATEST = 0 ;
/** Страница «самые свежие» в callback_data. Номер сообщения так не выглядит: номера бывают и отрицательными . */
const LATEST = "L" ;
const DEL_USAGE =
"Формат: /archive_del 120-130 135 — номера сообщений из /archive (можно диапазоны через дефис). " ;
/** Номер сообщения или ID чата: у импортированной истории старых групп и у групп они отрицательные. */
const NUM = "(-?\\d+) ";
const preview = ( text : string ) : string = >
text . length > PREVIEW_LENGTH ? ` ${ text . slice ( 0 , PREVIEW_LENGTH ) } … ` : text ;
/** «120-130 135» → [{120..130}, {135..135}]; undefined — если что-то не разобралось. */
const parseRanges = ( args : string ) : { from : number ; to : number } [ ] | undefined = > {
const tokens = args . split ( /[\s,]+/ ) . filter ( Boolean ) ;
const chunk = < T > ( items : T [ ] , size : number ) : T [ ] [ ] = >
items . length === 0 ? [ ] : [ items . slice ( 0 , size ) , . . . chunk ( items . slice ( size ) , size ) ] ;
/** «120-130 135 -999307862» → диапазоны; undefined — если что-то не разобралось. */
const parseRanges = ( tokens : string [ ] ) : { from : number ; to : number } [ ] | undefined = > {
const ranges = tokens . map ( ( token ) = > {
const match = token . match ( /^(\d+)(?:-(\d+))?$/ ) ;
const match = token . match ( /^(-? \d+)(?:-(-? \d+))?$/ ) ;
if ( ! match ) return undefined ;
@@ -35,22 +36,53 @@ const parseRanges = (args: string): { from: number; to: number }[] | undefined =
return tokens . length > 0 && ranges . every ( ( range ) = > range !== undefined ) ? ranges : undefined ;
} ;
const chunk = < T > ( items : T [ ] , size : number ) : T [ ] [ ] = >
items . length === 0 ? [ ] : [ items . slice ( 0 , size ) , . . . chunk ( items . slice ( size ) , size ) ] ;
const isPrivate = ( ctx : Context ) : boolean = > ctx . chat ? . type === "private" ;
/** Просмотр и удаление сообщений архива чата. Подключать только за фильтром администратора . */
/** Как называть чат в списке: название группы или имя в личке; если Telegram не ответил — ID . */
const chatTitle = async ( ctx : Context , chatId : number ) : Promise < string > = > {
const chat = await ctx . api . getChat ( chatId ) . catch ( ( ) = > undefined ) ;
if ( ! chat ) return String ( chatId ) ;
return "title" in chat && chat . title ? chat . title : ` личка ${ chat . first_name ? ? chatId } ` ;
} ;
/** Просмотр и удаление сообщений архива. В группе — её архив, в личке с ботом — любой чат на выбор. Только для админов. */
export const createArchiveHandlers = ( chatHistoryService : ChatHistoryService ) = > {
const composer = new Composer ( ) ;
/** Страница архива: сообщения до `before` (или самые свежие) с кнопками удаления и листания . */
const renderPage = ( chatId : number , before : number ) = > {
const messages = chatHistoryService . page ( chatId , PAGE_SIZE , before === LATEST ? undefined : before ) ;
/** Команда удаления пачки для заголовка: в личке к ней нужен ID чата . */
const delCommand = ( chatId : number , inPrivate : boolean ) = > ` /archive_del ${ inPrivate ? ` ${ chatId } ` : "" } ` ;
const renderChats = async ( ctx : Context ) = > {
const chats = chatHistoryService . chats ( ) ;
if ( chats . length === 0 ) return { text : "Архив пуст — бот ещё ничего не сохранил." , reply_markup : new InlineKeyboard ( ) } ;
const titles = await Promise . all ( chats . map ( ( chat ) = > chatTitle ( ctx , chat . chatId ) ) ) ;
const keyboard = chats . reduce (
( kb , chat , i ) = > kb . text ( ` ${ titles [ i ] } — ${ chat . count } ` , ` arch:chat: ${ chat . chatId } ` ) . row ( ) ,
new InlineKeyboard ( ) ,
) ;
return { text : "Какой архив открыть?" , reply_markup : keyboard } ;
} ;
/** Страница архива чата: сообщения до `before` (или самые свежие) с кнопками удаления и листания. */
const renderPage = ( chatId : number , before : string , inPrivate : boolean ) = > {
const messages = chatHistoryService . page ( chatId , PAGE_SIZE , before === LATEST ? undefined : Number ( before ) ) ;
const { count } = chatHistoryService . stats ( chatId ) ;
const backToChats = inPrivate ? [ InlineKeyboard . text ( "📂 Чаты" , "arch:list" ) ] : [ ] ;
if ( messages . length === 0 ) {
const text = before === LATEST ? "Архив этого чата пуст." : "Раньше сообщений нет." ;
return { text , reply_markup : new InlineKeyboard ( ) . text ( "⏭ К последним" , ` arch:page: ${ LATEST } ` ) } ;
return {
text ,
reply_markup : InlineKeyboard.from ( [ [ InlineKeyboard . text ( "⏭ К последним" , ` arch:page: ${ chatId } : ${ LATEST } ` ) , . . . backToChats ] ] ) ,
} ;
}
const lines = messages . map ( ( m ) = > ` # ${ m . messageId } [ ${ formatUtc ( m . date ) } ] ${ m . author } : ${ preview ( m . text ) } ` ) ;
@@ -59,61 +91,112 @@ export const createArchiveHandlers = (chatHistoryService: ChatHistoryService) =>
const hasOlder = chatHistoryService . page ( chatId , 1 , oldest ) . length > 0 ;
const deleteRows = chunk ( messages , BUTTONS_PER_ROW ) . map ( ( row ) = >
row . map ( ( m ) = > InlineKeyboard . text ( ` 🗑 ${ m . messageId } ` , ` arch:del: ${ m . messageId } : ${ before } ` ) ) ,
row . map ( ( m ) = > InlineKeyboard . text ( ` 🗑 ${ m . messageId } ` , ` arch:del: ${ chatId } : ${ m. messageId } : ${ before } ` ) ) ,
) ;
const navRow = [
. . . ( hasOlder ? [ InlineKeyboard . text ( "⬅️ Раньше" , ` arch:page: ${ oldest } ` ) ] : [ ] ) ,
. . . ( before !== LATEST ? [ InlineKeyboard . text ( "⏭ К последним" , ` arch:page: ${ LATEST } ` ) ] : [ ] ) ,
. . . ( hasOlder ? [ InlineKeyboard . text ( "⬅️ Раньше" , ` arch:page: ${ chatId } : ${ oldest} ` ) ] : [ ] ) ,
. . . ( before !== LATEST ? [ InlineKeyboard . text ( "⏭ К последним" , ` arch:page: ${ chatId } : ${ LATEST} ` ) ] : [ ] ) ,
. . . backToChats ,
] ;
const text = [ ` Архив чата: ${ count } сообщений. Удалить пачку: /archive_del 120-130 ` , "" , . . . lines ] . join ( "\n" ) ;
const cmd = delCommand ( chatId , inPrivate ) ;
const text = [
` Архив чата: ${ count } сообщений. ` ,
` Удалить пачку: ${ cmd } 120-130 · весь архив: ${ cmd } all ` ,
"" ,
. . . lines ,
] . join ( "\n" ) ;
return { text , reply_markup : InlineKeyboard.from ( [ . . . deleteRows , . . . ( navRow . length > 0 ? [ navRow ] : [ ] ) ] ) } ;
} ;
composer . command ( "archive" , async ( ctx ) = > {
const { text , reply_markup } = renderPage ( ctx . chat . id , LATEST ) ;
const edit = async ( ctx : Context , view : { text : string ; reply_markup : InlineKeyboard } ) = >
ctx . editMessageText ( view . text , { reply_markup : view.reply_markup } ) . catch ( ( ) = > { } ) ;
await ctx . reply ( text , { reply_markup } ) ;
composer . command ( "archive" , async ( ctx ) = > {
const view = isPrivate ( ctx ) ? await renderChats ( ctx ) : renderPage ( ctx . chat . id , LATEST , false ) ;
await ctx . reply ( view . text , { reply_markup : view.reply_markup } ) ;
} ) ;
composer . command ( "archive_del" , async ( ctx ) = > {
const range s = parseRanges ( ctx. match ) ;
const token s = ctx . match. split ( /[\s,]+/ ) . filter ( Boolean ) ;
const inPrivate = isPrivate ( ctx ) ;
if ( ! ranges ) {
await ctx . reply ( DEL_USAGE ) ;
const [ chatToken , . . . rest ] = inPrivate ? tokens : [ String ( ctx . chat . id ) , . . . tokens ] ;
const chatId = Number ( chatToken ) ;
const usage =
` Формат: ${ inPrivate ? "/archive_del <ID чата> 120-130 135" : "/archive_del 120-130 135" } — номера из /archive, ` +
` можно диапазоны через дефис; весь архив: ${ inPrivate ? "/archive_del <ID чата> all" : "/archive_del all" } . ` ;
if ( ! Number . isSafeInteger ( chatId ) || rest . length === 0 ) {
await ctx . reply ( usage ) ;
return ;
}
const removed = chatHistoryService . remove ( ctx . chat . id , ranges ) ;
if ( rest . length === 1 && rest [ 0 ] ! . toLowerCase ( ) === "all" ) {
const { count } = chatHistoryService . stats ( chatId ) ;
const keyboard = new InlineKeyboard ( )
. text ( ` 🗑 Да, стереть все ${ count } ` , ` arch:wipe: ${ chatId } ` )
. text ( "Отмена" , "arch:wipe_no" ) ;
await ctx . reply ( ` Стереть весь архив чата ${ chatId } ( ${ count } сообщений)? Вернуть можно только повторным /import. ` , {
reply_markup : keyboard ,
} ) ;
return ;
}
const ranges = parseRanges ( rest ) ;
if ( ! ranges ) {
await ctx . reply ( usage ) ;
return ;
}
const removed = chatHistoryService . remove ( chatId , ranges ) ;
await ctx . reply ( ` Удалил из архива ${ removed } сообщений. Посмотреть, что осталось: /archive ` ) ;
} ) ;
composer . callbackQuery ( /^ arch:del:(\d+):(\d+)$/ , async ( ctx ) = > {
const chatId = ctx . chat ? . id ;
if ( chatId === undefined ) return ctx . answerCallbackQuery ( ) ;
const messageId = Number ( ctx . match [ 1 ] ) ;
const removed = chatHistoryService . remove ( chatId , [ { from : messageId , to : messageId } ] ) ;
const { text , reply_markup } = renderPage ( chatId , Number ( ctx . match [ 2 ] ) ) ;
await ctx . answerCallbackQuery ( { text : removed > 0 ? ` Удалено # ${ messageId } ` : "Уже удалено" } ) ;
await ctx . editMessageText ( text , { reply_markup } ) . catch ( ( ) = > { } ) ;
composer . callbackQuery ( " arch:list" , async ( ctx ) = > {
await ctx . answerCallbackQuery ( ) ;
await edit ( ctx , await renderChats ( ctx ) ) ;
} ) ;
composer . callbackQuery ( /^arch:page:(\d+)$/ , async ( ctx ) = > {
const chatId = ctx . chat ? . id ;
if ( chatId === undefined ) return ctx . answerCallbackQuery ( ) ;
const { text , reply_markup } = renderPage ( chatId , Number ( ctx . match [ 1 ] ) ) ;
composer . callbackQuery ( new RegExp ( ` ^arch:chat: ${ NUM } $ ` ) , async ( ctx ) = > {
await ctx . answerCallbackQuery ( ) ;
await ctx . editMessageTex t( text , { reply_markup } ) . c atch( ( ) = > { } ) ;
await edi t( ctx , renderPage ( Number ( ctx . m atch[ 1 ] ) , LATEST , isPrivate ( ctx ) ) ) ;
} ) ;
composer . callbackQuery ( new RegExp ( ` ^arch:page: ${ NUM } :(-? \\ d+| ${ LATEST } ) $ ` ) , async ( ctx ) = > {
await ctx . answerCallbackQuery ( ) ;
await edit ( ctx , renderPage ( Number ( ctx . match [ 1 ] ) , ctx . match [ 2 ] ! , isPrivate ( ctx ) ) ) ;
} ) ;
composer . callbackQuery ( new RegExp ( ` ^arch:del: ${ NUM } : ${ NUM } :(-? \\ d+| ${ LATEST } ) $ ` ) , async ( ctx ) = > {
const chatId = Number ( ctx . match [ 1 ] ) ;
const messageId = Number ( ctx . match [ 2 ] ) ;
const removed = chatHistoryService . remove ( chatId , [ { from : messageId , to : messageId } ] ) ;
await ctx . answerCallbackQuery ( { text : removed > 0 ? ` Удалено # ${ messageId } ` : "Уже удалено" } ) ;
await edit ( ctx , renderPage ( chatId , ctx . match [ 3 ] ! , isPrivate ( ctx ) ) ) ;
} ) ;
composer . callbackQuery ( new RegExp ( ` ^arch:wipe: ${ NUM } $ ` ) , async ( ctx ) = > {
const removed = chatHistoryService . removeAll ( Number ( ctx . match [ 1 ] ) ) ;
await ctx . answerCallbackQuery ( { text : "Архив стёрт" } ) ;
await ctx . editMessageText ( ` Готово: стёр весь архив чата ( ${ removed } сообщений). ` ) . catch ( ( ) = > { } ) ;
} ) ;
composer . callbackQuery ( "arch:wipe_no" , async ( ctx ) = > {
await ctx . answerCallbackQuery ( { text : "Отменено" } ) ;
await ctx . editMessageText ( "Стирание архива отменено." ) . catch ( ( ) = > { } ) ;
} ) ;
return composer ;