удалён вход Telegram, владелец записи стал обязателен в схеме
- убраны клиент бота, транспорт обновлений, отправитель сообщений, сборка входа при старте, секция настроек и зависимость go-telegram-bot-api; из конвейера ушла доставка ответа отправителю — исход виден опросом готовности. Колонки адресата и значение источника остались в схеме: применённые шаги не переписываются - шаг 202608140003 запрещает пустого владельца у аудиозаписи и у файла; существующие строки он не проверяет, и это принято сознательно — искать их надо запросом до выкладки - ревью нашло два пред-существующих дефекта, оба закрыты: пустой второй ответ распознавателя стирал сохранённую расшифровку, а пустая расшифровка перестала быть заметной вместе с убранной доставкой. Попутно поднят golang.org/x/image до v0.45.0 — красный шаг vulns, воспроизводился и на чистом master
This commit is contained in:
@@ -0,0 +1,77 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
)
|
||||
|
||||
// up202608140003 запрещает пустого владельца у аудиозаписи и у её файла.
|
||||
//
|
||||
// Прежде пустое значение допускалось, и цену за это платили записи, принятые
|
||||
// ботом: связи чата Telegram с учётной записью сервис не вёл, и владельца у них
|
||||
// не было вовсе. Вход Telegram убран, заводить ничью запись стало некому, и
|
||||
// обязательность переезжает из приёма в схему — туда, где её держит хранилище, а
|
||||
// не договорённость. Разница не косметическая: пока обязательность жила в
|
||||
// приёме, ничью запись заводили руками в панели, она уходила в конвейер, стоила
|
||||
// денег на распознавание и не доставалась потом никому.
|
||||
//
|
||||
// Существующих строк шаг **не смотрит**, и это проверено прогоном: хранилище
|
||||
// держит обязательность связи проверкой записи при сохранении, а не ограничением
|
||||
// таблицы, поэтому смена признака на базе с ничьей записью проходит зелёным и
|
||||
// такую запись оставляет. Искать ничьи строки надо до выкладки и запросом —
|
||||
// `SELECT count(*) FROM audio_records WHERE owner = ”` и то же по `files`;
|
||||
// прогон самого шага на копии этого не показывает.
|
||||
//
|
||||
// Оставленная ничья запись становится незакрываемой: захват идёт сырым запросом
|
||||
// мимо проверки и выдаёт её воркеру, а всякое сохранение — включая то, которым
|
||||
// ставится признак остановки, — отказывает. Порядок выкладки поэтому начинается
|
||||
// с проверки данных, а не с прогона шага.
|
||||
func up202608140003(app core.App) error {
|
||||
for _, name := range []string{RecordsCollection, FilesCollection} {
|
||||
if err := setOwnerRequired(app, name, true); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// down202608140003 возвращает колонке необязательность. Записей это не касается:
|
||||
// пустых значений среди них нет, а появиться им теперь неоткуда.
|
||||
func down202608140003(app core.App) error {
|
||||
for _, name := range []string{RecordsCollection, FilesCollection} {
|
||||
if err := setOwnerRequired(app, name, false); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// setOwnerRequired правит признак обязательности у колонки владельца одной
|
||||
// коллекции. Колонка ищется по имени и приводится к типу связи: шаг, молча
|
||||
// пропустивший чужой тип, оставил бы схему в состоянии, о котором никто не
|
||||
// узнает.
|
||||
func setOwnerRequired(app core.App, collectionName string, required bool) error {
|
||||
collection, err := app.FindCollectionByNameOrId(collectionName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to find collection %s: %w", collectionName, err)
|
||||
}
|
||||
|
||||
field := collection.Fields.GetByName("owner")
|
||||
if field == nil {
|
||||
return errors.New("collection " + collectionName + " has no owner field")
|
||||
}
|
||||
|
||||
relation, ok := field.(*core.RelationField)
|
||||
if !ok {
|
||||
return errors.New("owner field of collection " + collectionName + " is not a relation")
|
||||
}
|
||||
|
||||
relation.Required = required
|
||||
|
||||
if err := app.Save(collection); err != nil {
|
||||
return fmt.Errorf("failed to change owner requirement in %s: %w", collectionName, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -49,6 +49,7 @@ func init() {
|
||||
pbmigrations.Register(up202608120001, down202608120001, "202608120001_oidc_login.go")
|
||||
pbmigrations.Register(up202608140001, down202608140001, "202608140001_record_owner.go")
|
||||
pbmigrations.Register(up202608140002, down202608140002, "202608140002_record_centric_model.go")
|
||||
pbmigrations.Register(up202608140003, down202608140003, "202608140003_owner_required.go")
|
||||
}
|
||||
|
||||
func ptr[T any](v T) *T { return &v }
|
||||
|
||||
@@ -42,9 +42,7 @@ func newRecordOf(t *testing.T, app core.App, ownerID string) *entity.AudioRecord
|
||||
State: entity.StateUploaded,
|
||||
StateEnteredAt: clock.Now(),
|
||||
Source: entity.SourceApi,
|
||||
}
|
||||
if ownerID != "" {
|
||||
record.OwnerID = &ownerID
|
||||
OwnerID: ownerID,
|
||||
}
|
||||
require.NoError(t, NewAudioRecordRepository(app).Create(record))
|
||||
return record
|
||||
@@ -65,8 +63,7 @@ func TestGuardOwnerDeletion(t *testing.T) {
|
||||
|
||||
after, err := NewAudioRecordRepository(app).Get(record.Id)
|
||||
require.NoError(t, err, "запись на месте")
|
||||
require.NotNil(t, after.OwnerID, "и владелец у неё прежний")
|
||||
assert.Equal(t, account.Id, *after.OwnerID)
|
||||
assert.Equal(t, account.Id, after.OwnerID, "и владелец у неё прежний")
|
||||
}
|
||||
|
||||
// Считаются все коллекции с владельцем, а не одни записи: файл переживает свою
|
||||
@@ -119,19 +116,52 @@ func TestGuardOwnerDeletionLetsEmptyAccountGo(t *testing.T) {
|
||||
require.NoError(t, app.Delete(account), "пустая учётная запись удаляется")
|
||||
}
|
||||
|
||||
// Умолчания у колонки владельца нет: запись, чей владелец не назван, не
|
||||
// достаётся никому по недосмотру схемы.
|
||||
func TestOwnerColumnHasNoDefault(t *testing.T) {
|
||||
// Колонка владельца пустого значения не принимает и умолчания не имеет:
|
||||
// ничьей записи в хранилище не бывает, и завести её нечем — ни приёмом, ни
|
||||
// конвейером, ни рукой в панели.
|
||||
func TestOwnerColumnRefusesEmptyValue(t *testing.T) {
|
||||
app := newTestStorage(t)
|
||||
|
||||
records, err := app.FindCollectionByNameOrId(migrations.RecordsCollection)
|
||||
require.NoError(t, err)
|
||||
for _, name := range []string{migrations.RecordsCollection, migrations.FilesCollection} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
collection, err := app.FindCollectionByNameOrId(name)
|
||||
require.NoError(t, err)
|
||||
|
||||
field := records.Fields.GetByName("owner")
|
||||
require.NotNil(t, field, "колонка владельца заведена")
|
||||
field := collection.Fields.GetByName("owner")
|
||||
require.NotNil(t, field, "колонка владельца заведена")
|
||||
|
||||
relation, ok := field.(*core.RelationField)
|
||||
require.True(t, ok, "владелец — связь с учётной записью, а не строка")
|
||||
assert.False(t, relation.Required, "пустое значение допустимо ради записей бота")
|
||||
assert.False(t, relation.CascadeDelete, "удаление учётной записи не уносит архив следом")
|
||||
relation, ok := field.(*core.RelationField)
|
||||
require.True(t, ok, "владелец — связь с учётной записью, а не строка")
|
||||
assert.True(t, relation.Required, "пустое значение колонка не принимает")
|
||||
assert.False(t, relation.CascadeDelete, "удаление учётной записи не уносит архив следом")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Та же норма со стороны сохранения: схема отвергает запись без владельца, а не
|
||||
// только объявляет колонку обязательной.
|
||||
func TestStorageRefusesRecordWithoutOwner(t *testing.T) {
|
||||
app := newTestStorage(t)
|
||||
|
||||
record := &entity.AudioRecord{
|
||||
State: entity.StateUploaded,
|
||||
StateEnteredAt: clock.Now(),
|
||||
Source: entity.SourceApi,
|
||||
}
|
||||
|
||||
require.Error(t, NewAudioRecordRepository(app).Create(record),
|
||||
"ничья запись в хранилище не ложится")
|
||||
}
|
||||
|
||||
// И файл — наравне с записью: разное правило у них читалось бы как недосмотр.
|
||||
func TestStorageRefusesFileWithoutOwner(t *testing.T) {
|
||||
app := newTestStorage(t)
|
||||
|
||||
repo := NewFileRepository(app)
|
||||
work, err := repo.Stage(".mp3", strings.NewReader("запись"))
|
||||
require.NoError(t, err)
|
||||
defer func() { require.NoError(t, work.Close()) }()
|
||||
|
||||
_, err = repo.Create("sample.mp3", work, contract.FileMeta{Format: "mp3"}, "")
|
||||
require.Error(t, err, "ничей файл в хранилище не ложится")
|
||||
}
|
||||
|
||||
@@ -69,7 +69,7 @@ func updateByRequest(t *testing.T, app core.App, recordID string, body map[strin
|
||||
func haltedRecord(t *testing.T, app core.App) *entity.AudioRecord {
|
||||
t.Helper()
|
||||
|
||||
record := newRecordOf(t, app, "")
|
||||
record := newRecordOf(t, app, newAccount(t, app).Id)
|
||||
record.MoveToState(entity.StateNormalized)
|
||||
record.Attempts = 4
|
||||
record.AcquisitionID = ptrOf("прежний-захват")
|
||||
@@ -143,7 +143,7 @@ func TestPanelResumeIsLogged(t *testing.T) {
|
||||
func TestPanelStateEditClearsGuards(t *testing.T) {
|
||||
app := newPanelStorage(t)
|
||||
|
||||
record := newRecordOf(t, app, "")
|
||||
record := newRecordOf(t, app, newAccount(t, app).Id)
|
||||
record.Attempts = 4
|
||||
record.AcquisitionID = ptrOf("прежний-захват")
|
||||
record.AcquireExpiresAt = ptrOf(clock.Now().Add(8 * time.Hour))
|
||||
@@ -162,7 +162,7 @@ func TestPanelStateEditClearsGuards(t *testing.T) {
|
||||
func TestPanelKeepsGuardsOnUnrelatedEdit(t *testing.T) {
|
||||
app := newPanelStorage(t)
|
||||
|
||||
record := newRecordOf(t, app, "")
|
||||
record := newRecordOf(t, app, newAccount(t, app).Id)
|
||||
record.Attempts = 3
|
||||
record.AcquisitionID = ptrOf("живой-захват")
|
||||
require.NoError(t, NewAudioRecordRepository(app).Save(record, ""))
|
||||
@@ -181,7 +181,7 @@ func TestAcquireCarriesStageDeadline(t *testing.T) {
|
||||
app := newTestStorage(t)
|
||||
repo := NewAudioRecordRepository(app)
|
||||
|
||||
record := newRecordOf(t, app, "")
|
||||
record := newRecordOf(t, app, newAccount(t, app).Id)
|
||||
|
||||
acquired, err := repo.FindAndAcquire(entity.WorkingStages())
|
||||
require.NoError(t, err)
|
||||
@@ -205,7 +205,7 @@ func TestAcquireHandsRecordToExactlyOne(t *testing.T) {
|
||||
app := newTestStorage(t)
|
||||
repo := NewAudioRecordRepository(app)
|
||||
|
||||
newRecordOf(t, app, "")
|
||||
newRecordOf(t, app, newAccount(t, app).Id)
|
||||
|
||||
first, err := repo.FindAndAcquire(entity.WorkingStages())
|
||||
require.NoError(t, err, "первому запись досталась")
|
||||
@@ -223,7 +223,7 @@ func TestRottenAcquisitionIsHandedOutAgain(t *testing.T) {
|
||||
app := newTestStorage(t)
|
||||
repo := NewAudioRecordRepository(app)
|
||||
|
||||
record := newRecordOf(t, app, "")
|
||||
record := newRecordOf(t, app, newAccount(t, app).Id)
|
||||
|
||||
first, err := repo.FindAndAcquire(entity.WorkingStages())
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -50,18 +50,16 @@ func applyToRecord(record *core.Record, r *entity.AudioRecord) {
|
||||
// Владелец кладётся только здесь, при заведении. В applyOwnedByPipeline его
|
||||
// нет намеренно: конвейер владельца не назначает и не меняет, а снимок шага,
|
||||
// записанный поверх, стёр бы его молча.
|
||||
record.Set("owner", derefString(r.OwnerID))
|
||||
record.Set("owner", r.OwnerID)
|
||||
record.Set("source", r.Source)
|
||||
record.Set("title", derefString(r.Title))
|
||||
record.Set("brief", derefString(r.Brief))
|
||||
record.Set("tg_chat_id", derefInt64(r.TgChatId))
|
||||
record.Set("tg_reply_message_id", derefInt(r.TgReplyMessageId))
|
||||
}
|
||||
|
||||
func recordToAudioRecord(record *core.Record) *entity.AudioRecord {
|
||||
return &entity.AudioRecord{
|
||||
Id: record.Id,
|
||||
OwnerID: nilIfEmpty(record.GetString("owner")),
|
||||
OwnerID: record.GetString("owner"),
|
||||
Source: record.GetString("source"),
|
||||
Title: nilIfEmpty(record.GetString("title")),
|
||||
Brief: nilIfEmpty(record.GetString("brief")),
|
||||
@@ -80,8 +78,6 @@ func recordToAudioRecord(record *core.Record) *entity.AudioRecord {
|
||||
LiteraryTextID: nilIfEmpty(record.GetString("literary_text")),
|
||||
StructureID: nilIfEmpty(record.GetString("structure")),
|
||||
RecognitionID: nilIfEmpty(record.GetString("recognition")),
|
||||
TgChatId: nilIfZero64(int64(record.GetInt("tg_chat_id"))),
|
||||
TgReplyMessageId: nilIfZeroInt(record.GetInt("tg_reply_message_id")),
|
||||
CreatedAt: record.GetDateTime("created").Time(),
|
||||
UpdatedAt: record.GetDateTime("updated").Time(),
|
||||
}
|
||||
@@ -94,20 +90,6 @@ func derefString(v *string) string {
|
||||
return *v
|
||||
}
|
||||
|
||||
func derefInt64(v *int64) int64 {
|
||||
if v == nil {
|
||||
return 0
|
||||
}
|
||||
return *v
|
||||
}
|
||||
|
||||
func derefInt(v *int) int {
|
||||
if v == nil {
|
||||
return 0
|
||||
}
|
||||
return *v
|
||||
}
|
||||
|
||||
// dateOrEmpty отдаёт пустое значение вместо нулевой даты: пустая колонка даты в
|
||||
// хранилище это пустая строка, и она же значит «времени нет».
|
||||
func dateOrEmpty(v *time.Time) any {
|
||||
@@ -135,17 +117,3 @@ func timeOrNil(v types.DateTime) *time.Time {
|
||||
t := v.Time()
|
||||
return &t
|
||||
}
|
||||
|
||||
func nilIfZero64(v int64) *int64 {
|
||||
if v == 0 {
|
||||
return nil
|
||||
}
|
||||
return &v
|
||||
}
|
||||
|
||||
func nilIfZeroInt(v int) *int {
|
||||
if v == 0 {
|
||||
return nil
|
||||
}
|
||||
return &v
|
||||
}
|
||||
|
||||
@@ -58,7 +58,7 @@ func (repo *AudioRecordRepository) Create(r *entity.AudioRecord) error {
|
||||
// перевыданный другому — по протуханию срока или после того, как человек снял
|
||||
// признак остановки в панели, — обязан обратить запись первого в отказ; условие
|
||||
// по непустоте признака пропустило бы обоих, и два шага записали бы в одну
|
||||
// запись и оба ответили бы отправителю.
|
||||
// запись по очереди, портя её результат.
|
||||
func (repo *AudioRecordRepository) Save(r *entity.AudioRecord, holder string) error {
|
||||
return repo.app.RunInTransaction(func(txApp core.App) error {
|
||||
record, err := txApp.FindRecordById(migrations.RecordsCollection, r.Id)
|
||||
@@ -89,9 +89,10 @@ func (repo *AudioRecordRepository) Save(r *entity.AudioRecord, holder string) er
|
||||
// по разнице ответов иначе перебирается список заведённых записей, а
|
||||
// идентификатор записи и есть то, что разграничение прячет.
|
||||
//
|
||||
// Пустой ownerID отсекается **до** чтения и не совпадает ни с чем: иначе
|
||||
// вызывающий без учётной записи получил бы ровно множество записей без
|
||||
// владельца, то есть все записи бота.
|
||||
// Пустой ownerID отсекается **до** чтения и не совпадает ни с чем. Правило это
|
||||
// не стало избыточным с обязательностью колонки: схема запрещает **заводить**
|
||||
// ничью запись, а здесь запрещено **спрашивать** ничьим именем — иначе
|
||||
// вызывающий без учётной записи получил бы выборку вместо отказа.
|
||||
func (repo *AudioRecordRepository) GetByID(id, ownerID string) (*entity.AudioRecord, error) {
|
||||
if ownerID == "" {
|
||||
return nil, &contract.JobNotFoundError{Message: "record not found"}
|
||||
|
||||
@@ -26,6 +26,15 @@ func NewTextRepository(app core.App) *TextRepository {
|
||||
// Замена, а не вставка: пара «запись и вид» уникальна, и повтор прерванного шага
|
||||
// иначе завёл бы второй комплект строк — тогда вопрос «какой текст отдавать
|
||||
// человеку» стал бы вопросом порядка записи, а не состояния.
|
||||
//
|
||||
// **Пустое не кладётся поверх непустого**, и это не осторожность, а защита
|
||||
// архива. Повторный опрос той же операции — обычное дело: держатель захвата
|
||||
// умер, сохранение рубежа отказало, человек снял остановку в панели. Провайдер
|
||||
// при этом вправе ответить пустым потоком, отказом это не считается, и
|
||||
// безусловная замена стирала бы сохранённую расшифровку живого человека без
|
||||
// следа и без возврата. Та же защита стоит у сырого ответа провайдера
|
||||
// (`RecognitionRepository.Finish`), и разное правило у двух хранителей одного
|
||||
// результата читалось бы как недосмотр.
|
||||
func (repo *TextRepository) Put(recordID, kind, contents string) (*entity.Text, error) {
|
||||
collection, err := findCollection(repo.app, migrations.TextsCollection)
|
||||
if err != nil {
|
||||
@@ -50,6 +59,11 @@ func (repo *TextRepository) Put(recordID, kind, contents string) (*entity.Text,
|
||||
// запись вместо правды о недоступной базе.
|
||||
return nil, fmt.Errorf("failed to look up text of kind %s for record %s: %w", kind, recordID, err)
|
||||
}
|
||||
// Прежнее непустое содержимое пустым не заменяется: строка остаётся как
|
||||
// есть, и вызывающий получает её обратно.
|
||||
if contents == "" && record.GetString("contents") != "" {
|
||||
return textFromRecord(record), nil
|
||||
}
|
||||
record.Set("contents", contents)
|
||||
|
||||
if err := repo.app.Save(record); err != nil {
|
||||
@@ -106,7 +120,12 @@ func (repo *StructureRepository) Put(recordID string, version int, replicas []en
|
||||
)
|
||||
switch {
|
||||
case err == nil:
|
||||
// Строка есть — заменяем содержимое.
|
||||
// Строка есть — заменяем содержимое. Пустой перечень реплик поверх
|
||||
// непустого не кладётся по тому же доводу, что и у текста: повторный
|
||||
// опрос с пустым ответом провайдера стирал бы разбор живой записи.
|
||||
if len(replicas) == 0 && len(record.GetString("contents")) > len("[]") {
|
||||
return repo.GetByID(record.Id)
|
||||
}
|
||||
case errors.Is(err, sql.ErrNoRows):
|
||||
record = core.NewRecord(collection)
|
||||
record.Set("record", recordID)
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
package telegram
|
||||
|
||||
import (
|
||||
"git.vakhrushev.me/av/transcriber/internal/contract"
|
||||
)
|
||||
|
||||
// AbsentMessageSender подставляется вместо отправителя Telegram, когда вход
|
||||
// выключен признаком `telegram.enabled` либо Telegram оказался недоступен, и
|
||||
// клиента заводить не из чего. Он ничего не отправляет и на всякий ответ отдаёт
|
||||
// `contract.ErrDeliveryChannelDown`.
|
||||
//
|
||||
// Заглушка, а не пустой отправитель: необязательная зависимость, доехавшая до
|
||||
// ядра нулём, роняет процесс на первой же задаче из Telegram, а проверка на
|
||||
// месте употребления завела бы в ядре знание о том, как собран сервис.
|
||||
//
|
||||
// Молчит он намеренно. Записать недоставку заглушке нечем: контракт отправки
|
||||
// несёт текст, чат и сообщение для ответа, а идентификатора задачи в нём нет.
|
||||
// Пишет поэтому шаг конвейера, который задачу знает.
|
||||
type AbsentMessageSender struct{}
|
||||
|
||||
func NewAbsentMessageSender() *AbsentMessageSender {
|
||||
return &AbsentMessageSender{}
|
||||
}
|
||||
|
||||
func (s *AbsentMessageSender) Send(_ string, _ int64, _ *int) error {
|
||||
return contract.ErrDeliveryChannelDown
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
package telegram
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"git.vakhrushev.me/av/transcriber/internal/contract"
|
||||
)
|
||||
|
||||
// Заглушка отдаёт «канал не поднят» и молчит: записать недоставку ей нечем —
|
||||
// идентификатора задачи контракт отправки не несёт, и пишет её шаг конвейера.
|
||||
func TestAbsentSenderReportsChannelDown(t *testing.T) {
|
||||
sender := NewAbsentMessageSender()
|
||||
|
||||
err := sender.Send("расшифровка записи", 100, nil)
|
||||
|
||||
require.ErrorIs(t, err, contract.ErrDeliveryChannelDown)
|
||||
}
|
||||
|
||||
// Непустой годный токен по-прежнему даёт настоящего отправителя: прежний путь
|
||||
// сохранён, и меняется только то, что клиента теперь отдают готовым.
|
||||
func TestSenderIsBuiltFromLiveBot(t *testing.T) {
|
||||
bot, _ := newProbeBot(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||
if _, err := w.Write([]byte(getMeResponse)); err != nil {
|
||||
t.Errorf("подставной Telegram не смог ответить: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
sender := NewTelegramMessageSender(bot, slog.New(slog.DiscardHandler))
|
||||
|
||||
require.NotNil(t, sender)
|
||||
assert.Same(t, bot, sender.bot, "отправитель говорит с тем же клиентом, что и транспорт")
|
||||
}
|
||||
@@ -1,134 +0,0 @@
|
||||
package telegram
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
|
||||
)
|
||||
|
||||
// ErrEmptyToken — ключ доступа пуст при включённом входе, то есть **ошибка
|
||||
// настройки**: старт роняется. Отдельным значением, чтобы отличаться от
|
||||
// недоступности Telegram, у которой исход обратный — подъём без бота.
|
||||
//
|
||||
// Отказ от входа Telegram этим значением больше не выражается: намерение
|
||||
// объявляет признак включения `telegram.enabled`, и выключенный вход отсеивается
|
||||
// до всякого обращения сюда. Пустой ключ ловит проверка настроек ещё раньше,
|
||||
// поэтому сюда он доходит только в обход проверки.
|
||||
var ErrEmptyToken = errors.New("telegram bot token is empty")
|
||||
|
||||
// NewBot заводит клиента Bot API — и это **единая точка**, через которую с
|
||||
// библиотекой разговаривают оба пакета: адаптер отправки и транспорт бота.
|
||||
//
|
||||
// Точка нужна ради инварианта «секрет не покидает конфиг». Токен живёт в пути
|
||||
// каждого обращения к Bot API (`https://api.telegram.org/bot<TOKEN>/getFile`),
|
||||
// а `http.Client` кладёт адрес запроса в `*url.Error` целиком. Библиотека
|
||||
// отдаёт этот отказ вызывающему как есть, поэтому чистка на месте употребления
|
||||
// закрывает ровно один вызов из пяти: остаются `getFile`, `sendMessage`,
|
||||
// `getMe` из конструктора и длинный опрос. Здесь закрыты все.
|
||||
func NewBot(token string, logger *slog.Logger) (*tgbotapi.BotAPI, error) {
|
||||
return newBot(token, tgbotapi.APIEndpoint, logger)
|
||||
}
|
||||
|
||||
// newBot принимает адрес отдельно — иначе проверка утечки токена ходила бы за
|
||||
// подтверждением в живой Telegram, а боевым токеном запускаться запрещено.
|
||||
func newBot(token, endpoint string, logger *slog.Logger) (*tgbotapi.BotAPI, error) {
|
||||
if token == "" {
|
||||
return nil, ErrEmptyToken
|
||||
}
|
||||
|
||||
// Длинный опрос живёт внутри библиотеки и печатает свой отказ пакетным
|
||||
// логгером в stderr (`GetUpdatesChan`), минуя и наш `slog`, и чистку выше.
|
||||
// Это самый частый путь: опрос идёт непрерывно, а скачивание — только когда
|
||||
// кто-то прислал запись. Логгер пакетный, поэтому и подменяется один раз.
|
||||
if err := tgbotapi.SetLogger(&redactingLogger{token: token, logger: logger}); err != nil {
|
||||
return nil, fmt.Errorf("failed to set telegram logger: %w", err)
|
||||
}
|
||||
|
||||
// Сборка ходит за `getMe` и стоит на пути старта — раньше HTTP-сервера,
|
||||
// панели и воркеров. Без срока ожидания молчащий Telegram (соединение
|
||||
// принято, ответа нет) вешал бы весь подъём бессрочно: порт не слушается,
|
||||
// проба здоровья не отвечает, а в журнале ни строки.
|
||||
probe := &safeClient{inner: &http.Client{Timeout: ProbeTimeout}}
|
||||
|
||||
// Отказ конструктора чистится здесь, а не клиентом: адрес собирается
|
||||
// строкой с токеном внутри, и `http.NewRequest` падает на его разборе
|
||||
// **до** обращения к клиенту — то есть мимо `safeClient`. Токен с
|
||||
// управляющим символом или неверной `%`-последовательностью иначе уезжает
|
||||
// в журнал целиком: перенос строки в конце значения ловится так же.
|
||||
bot, err := tgbotapi.NewBotAPIWithClient(token, endpoint, probe)
|
||||
if err != nil {
|
||||
return nil, WithoutURL(err)
|
||||
}
|
||||
|
||||
// Дальше живёт длинный опрос, и срок ему не нужен: он ждёт обновлений
|
||||
// столько, сколько задано настройкой, и клиент со сроком рвал бы его.
|
||||
bot.Client = &safeClient{inner: &http.Client{}}
|
||||
|
||||
return bot, nil
|
||||
}
|
||||
|
||||
// ProbeTimeout — сколько ждём Telegram при сборке клиента. Число выбрано
|
||||
// решением, а не замером: одно обращение за `getMe` укладывается в доли
|
||||
// секунды, а десять секунд — потолок, после которого Telegram считается
|
||||
// недоступным и сервис поднимается без него.
|
||||
const ProbeTimeout = 10 * time.Second
|
||||
|
||||
// safeClient — клиент, чей отказ не несёт адреса. Библиотека объявляет
|
||||
// зависимость интерфейсом `HTTPClient` и возвращает наш отказ вызывающему
|
||||
// нетронутым, поэтому чистка отсюда доходит до каждого вызова Bot API.
|
||||
type safeClient struct {
|
||||
inner *http.Client
|
||||
}
|
||||
|
||||
func (c *safeClient) Do(req *http.Request) (*http.Response, error) {
|
||||
resp, err := c.inner.Do(req)
|
||||
if err != nil {
|
||||
return nil, WithoutURL(err)
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// WithoutURL снимает с отказа адрес запроса, сохраняя причину. Стандартный
|
||||
// клиент кладёт в `*url.Error` полный URL, а в ссылке Telegram стоит токен
|
||||
// бота: без этой чистки первый же сбой сети печатает секрет в журнал.
|
||||
// Причина остаётся и узнаётся `errors.Is` по-прежнему.
|
||||
func WithoutURL(err error) error {
|
||||
var urlErr *url.Error
|
||||
if errors.As(err, &urlErr) {
|
||||
return urlErr.Err
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// redactingLogger отдаёт сообщения библиотеки нашему журналу, вычеркнув токен.
|
||||
// Здесь чистится текст, а не ошибка: библиотека печатает уже отформатированную
|
||||
// строку, и разбирать в ней `*url.Error` нечего. Замена точная — токен известен.
|
||||
type redactingLogger struct {
|
||||
token string
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
const redactedToken = "«токен»"
|
||||
|
||||
func (l *redactingLogger) Println(v ...any) {
|
||||
l.write(strings.TrimSuffix(fmt.Sprintln(v...), "\n"))
|
||||
}
|
||||
|
||||
func (l *redactingLogger) Printf(format string, v ...any) {
|
||||
l.write(fmt.Sprintf(format, v...))
|
||||
}
|
||||
|
||||
// write пишет на WARN: это сбой фонового цикла со штатным повтором, а не
|
||||
// событие, требующее разбора (docs/conventions/logging.md, «Уровень —
|
||||
// это адресат»). Сообщение нейтрально: тем же логгером библиотека печатает и
|
||||
// отладку, если её включить, а разделить их она не даёт.
|
||||
func (l *redactingLogger) write(message string) {
|
||||
l.logger.Warn("Telegram library log",
|
||||
"message", strings.ReplaceAll(message, l.token, redactedToken))
|
||||
}
|
||||
@@ -1,172 +0,0 @@
|
||||
package telegram
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// Токен виден в пути каждого обращения к Bot API, а `http.Client` кладёт путь в
|
||||
// `*url.Error` целиком. Проверки ниже судят по тексту: секрет не должен
|
||||
// встречаться ни в отказе, ни в строке журнала. Утечка необратима — утёкший
|
||||
// токен отзывают руками (CLAUDE.md, «Инварианты», critical).
|
||||
const probeToken = "7654321:AAHsecretBOTtokenVALUE"
|
||||
|
||||
// getMeResponse — ответ, которым подставной Telegram пускает конструктор
|
||||
// дальше: `NewBotAPIWithClient` ходит за `getMe` прежде, чем отдать клиента.
|
||||
const getMeResponse = `{"ok":true,"result":{"id":1,"is_bot":true,"first_name":"probe","username":"probe_bot"}}`
|
||||
|
||||
func newProbeBot(t *testing.T, handler http.HandlerFunc) (*tgbotapi.BotAPI, *httptest.Server) {
|
||||
t.Helper()
|
||||
|
||||
server := httptest.NewServer(handler)
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
bot, err := newBot(probeToken, server.URL+"/bot%s/%s", slog.New(slog.DiscardHandler))
|
||||
require.NoError(t, err)
|
||||
|
||||
return bot, server
|
||||
}
|
||||
|
||||
// Отказ транспорта на любом вызове Bot API не несёт токена: чистка стоит на
|
||||
// границе клиента, а не у места употребления, поэтому закрыты все вызовы разом.
|
||||
func TestBotAPIFailureDoesNotCarryToken(t *testing.T) {
|
||||
bot, server := newProbeBot(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||
if _, err := w.Write([]byte(getMeResponse)); err != nil {
|
||||
t.Errorf("подставной Telegram не смог ответить: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
// Собеседник исчез — так выглядит обрыв сети, DNS-сбой и недоступность
|
||||
// api.telegram.org.
|
||||
server.Close()
|
||||
|
||||
t.Run("getFile", func(t *testing.T) {
|
||||
_, err := bot.GetFile(tgbotapi.FileConfig{FileID: "any"})
|
||||
require.Error(t, err)
|
||||
assert.NotContains(t, err.Error(), probeToken, "токен уехал в отказ: %v", err)
|
||||
})
|
||||
|
||||
t.Run("sendMessage", func(t *testing.T) {
|
||||
_, err := bot.Send(tgbotapi.NewMessage(1, "текст"))
|
||||
require.Error(t, err)
|
||||
assert.NotContains(t, err.Error(), probeToken, "токен уехал в отказ: %v", err)
|
||||
})
|
||||
}
|
||||
|
||||
// Токен, ломающий разбор адреса, — второй путь отказа конструктора, и до
|
||||
// недавнего он был открыт: `http.NewRequest` падает раньше обращения к клиенту,
|
||||
// то есть мимо чистки на его границе. Так выглядит перенос строки, приехавший
|
||||
// с секретом из шаблона выкладки, и невычищенная `%`-последовательность.
|
||||
func TestBotConstructionFailureOnUnparsableTokenDoesNotCarryToken(t *testing.T) {
|
||||
broken := map[string]string{
|
||||
"перенос строки": probeToken + "\n",
|
||||
"негодная escape-пара": "7654321:AAH%zzSECRETtokenVALUE",
|
||||
}
|
||||
|
||||
for name, token := range broken {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
_, err := newBot(token, tgbotapi.APIEndpoint, slog.New(slog.DiscardHandler))
|
||||
|
||||
require.Error(t, err)
|
||||
assert.NotContains(t, err.Error(), token, "токен уехал в отказ: %v", err)
|
||||
assert.NotContains(t, err.Error(), "api.telegram.org", "адрес остался в отказе: %v", err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Отказ конструктора несёт тот же путь: `NewBotAPIWithClient` ходит за `getMe`,
|
||||
// и контейнер, стартующий раньше сети, печатал бы токен в первую же секунду.
|
||||
func TestBotConstructionFailureDoesNotCarryToken(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
|
||||
server.Close()
|
||||
|
||||
_, err := newBot(probeToken, server.URL+"/bot%s/%s", slog.New(slog.DiscardHandler))
|
||||
|
||||
require.Error(t, err)
|
||||
assert.NotContains(t, err.Error(), probeToken, "токен уехал в отказ конструктора: %v", err)
|
||||
}
|
||||
|
||||
// Длинный опрос печатает свои отказы пакетным логгером самой библиотеки, минуя
|
||||
// наш `slog`. Логгер подменён — значит, и эта строка идёт через вычистку.
|
||||
func TestLibraryLoggerRedactsToken(t *testing.T) {
|
||||
journal := &bytes.Buffer{}
|
||||
logger := slog.New(slog.NewTextHandler(journal, nil))
|
||||
|
||||
redacting := &redactingLogger{token: probeToken, logger: logger}
|
||||
redacting.Println(errors.New(`Post "https://api.telegram.org/bot` + probeToken + `/getUpdates": dial tcp: refused`))
|
||||
redacting.Printf("Failed to get updates from %s", "https://api.telegram.org/bot"+probeToken+"/getUpdates")
|
||||
|
||||
written := journal.String()
|
||||
assert.NotContains(t, written, probeToken, "токен уехал в журнал: %s", written)
|
||||
assert.Equal(t, 2, strings.Count(written, redactedToken), "вместо токена стоит пометка")
|
||||
assert.Contains(t, written, "dial tcp", "причина отказа осталась")
|
||||
}
|
||||
|
||||
// Пустой токен — законный исход подъёма без Telegram, и узнаётся он по смыслу.
|
||||
// Обратное тоже нормируется: отказ негодного токена не должен читаться как
|
||||
// отказ от входа, иначе сборка при старте подставит заглушку там, где нужен
|
||||
// отказ, и молча потеряет бота.
|
||||
func TestEmptyTokenIsRecognizedByValue(t *testing.T) {
|
||||
_, err := NewBot("", slog.New(slog.DiscardHandler))
|
||||
|
||||
require.ErrorIs(t, err, ErrEmptyToken)
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
|
||||
server.Close()
|
||||
|
||||
_, err = newBot(probeToken, server.URL+"/bot%s/%s", slog.New(slog.DiscardHandler))
|
||||
|
||||
require.Error(t, err)
|
||||
require.NotErrorIs(t, err, ErrEmptyToken)
|
||||
}
|
||||
|
||||
// WithoutURL снимает адрес, но не причину: `errors.Is` по цепочке продолжает
|
||||
// работать, иначе чистка стоила бы узнаваемости отказа.
|
||||
func TestWithoutURLKeepsCause(t *testing.T) {
|
||||
cause := errors.New("dial tcp: connection refused")
|
||||
wrapped := &url.Error{Op: "Post", URL: "https://api.telegram.org/bot" + probeToken + "/getMe", Err: cause}
|
||||
|
||||
cleaned := WithoutURL(wrapped)
|
||||
|
||||
assert.NotContains(t, cleaned.Error(), probeToken)
|
||||
require.ErrorIs(t, cleaned, cause)
|
||||
assert.Equal(t, cause, WithoutURL(cause), "отказ без адреса не трогают")
|
||||
}
|
||||
|
||||
// Стык, которого не сторожил никто: подмена пакетного логгера держится одной
|
||||
// строкой в `NewBot`, а снятие этой строки не роняло ни одной проверки. Оракул
|
||||
// косвенный по необходимости — библиотека не отдаёт установленный логгер
|
||||
// обратно, — поэтому он смотрит на исход: её собственная строка обязана
|
||||
// оказаться в нашем журнале.
|
||||
func TestLibraryLoggerIsActuallyInstalled(t *testing.T) {
|
||||
journal := &bytes.Buffer{}
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
if _, err := w.Write([]byte(getMeResponse)); err != nil {
|
||||
t.Errorf("подставной Telegram не смог ответить: %v", err)
|
||||
}
|
||||
}))
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
bot, err := newBot(probeToken, server.URL+"/bot%s/%s", slog.New(slog.NewTextHandler(journal, nil)))
|
||||
require.NoError(t, err)
|
||||
|
||||
// Отладку библиотека печатает тем же логгером, что и отказы: включаем её,
|
||||
// чтобы строка появилась без обрыва сети.
|
||||
bot.Debug = true
|
||||
_, err = bot.GetMe()
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Contains(t, journal.String(), "Telegram library log",
|
||||
"строка библиотеки прошла мимо нашего журнала: логгер не подменён")
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
package telegram
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
|
||||
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
|
||||
)
|
||||
|
||||
const (
|
||||
TextLengthLimit = 4000
|
||||
)
|
||||
|
||||
type TelegramMessageSender struct {
|
||||
bot *tgbotapi.BotAPI
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
// NewTelegramMessageSender принимает готового клиента, а не токен. Клиента
|
||||
// заводит сборка при старте — одного на отправителя и на транспорт бота: пока
|
||||
// его строили здесь и там порознь, два пути одного старта разошлись в том,
|
||||
// терпеть ли негодный токен, и согласовывать их приходилось руками.
|
||||
func NewTelegramMessageSender(bot *tgbotapi.BotAPI, logger *slog.Logger) *TelegramMessageSender {
|
||||
return &TelegramMessageSender{
|
||||
bot: bot,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *TelegramMessageSender) Send(text string, chatId int64, replyToMessageId *int) error {
|
||||
// If message is short enough, send it directly
|
||||
if len([]rune(text)) <= TextLengthLimit {
|
||||
return s.sendSingleMessage(text, chatId, replyToMessageId)
|
||||
}
|
||||
|
||||
// Split long message into parts
|
||||
parts := s.splitMessageByWords(text, TextLengthLimit)
|
||||
|
||||
// Send each part
|
||||
for i, part := range parts {
|
||||
var replyId *int
|
||||
// Only use replyToMessageId for the first part
|
||||
if i == 0 {
|
||||
replyId = replyToMessageId
|
||||
}
|
||||
err := s.sendSingleMessage(part, chatId, replyId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// sendSingleMessage sends a single message
|
||||
func (s *TelegramMessageSender) sendSingleMessage(text string, chatId int64, replyToMessageId *int) error {
|
||||
resultMsg := tgbotapi.NewMessage(chatId, text)
|
||||
if replyToMessageId != nil {
|
||||
resultMsg.ReplyToMessageID = *replyToMessageId
|
||||
}
|
||||
_, err := s.bot.Send(resultMsg)
|
||||
if err != nil {
|
||||
s.logger.Error("Failed to send message to tg bot", "error", err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
package telegram
|
||||
|
||||
// splitMessageByWords splits a message into parts of maxLen UTF-8 characters
|
||||
// splitting by words to avoid cutting words in the middle
|
||||
func (s *TelegramMessageSender) splitMessageByWords(text string, maxLen int) []string {
|
||||
var parts []string
|
||||
|
||||
// If text is already short enough, return as is
|
||||
if len([]rune(text)) <= maxLen {
|
||||
return []string{text}
|
||||
}
|
||||
|
||||
runes := []rune(text)
|
||||
|
||||
for len(runes) > 0 {
|
||||
// Determine the end position for this part
|
||||
end := len(runes)
|
||||
if end > maxLen {
|
||||
end = maxLen
|
||||
}
|
||||
|
||||
// Try to find a good split point (word boundary)
|
||||
splitPoint := end
|
||||
for i := end - 1; i > end-20 && i > 0; i-- { // Look back up to 20 characters
|
||||
// Check if this is a good split point (after a space)
|
||||
if runes[i] == ' ' {
|
||||
splitPoint = i + 1 // Include the space in the previous part
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// If we couldn't find a good split point, just split at maxLen
|
||||
if splitPoint == end && end == maxLen {
|
||||
// Check if we're in the middle of a word
|
||||
if end < len(runes) && runes[end] != ' ' && runes[end-1] != ' ' {
|
||||
// Try to find a split point going forward
|
||||
for i := end; i < len(runes) && i < end+20; i++ {
|
||||
if runes[i] == ' ' {
|
||||
splitPoint = i
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If still no good split point, use the original end
|
||||
if splitPoint > len(runes) {
|
||||
splitPoint = len(runes)
|
||||
}
|
||||
|
||||
// Add this part
|
||||
parts = append(parts, string(runes[:splitPoint]))
|
||||
|
||||
// Move to the next part
|
||||
if splitPoint >= len(runes) {
|
||||
break
|
||||
}
|
||||
runes = runes[splitPoint:]
|
||||
}
|
||||
|
||||
return parts
|
||||
}
|
||||
@@ -1,125 +0,0 @@
|
||||
package telegram
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestTelegramMessageSender_splitMessageByWords(t *testing.T) {
|
||||
sender := &TelegramMessageSender{}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
text string
|
||||
maxLen int
|
||||
expected []string
|
||||
}{
|
||||
{
|
||||
name: "Short text should return as is",
|
||||
text: "Привет мир",
|
||||
maxLen: 25,
|
||||
expected: []string{
|
||||
"Привет мир",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Text exactly at limit",
|
||||
text: "Это тестовый текст который ровно соответствует лимиту",
|
||||
maxLen: 35,
|
||||
expected: []string{
|
||||
"Это тестовый текст который ровно ",
|
||||
"соответствует ",
|
||||
"лимиту",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Text with word boundaries",
|
||||
text: "Это очень длинный текст для проверки работы функции разделения сообщения",
|
||||
maxLen: 25,
|
||||
expected: []string{
|
||||
"Это очень длинный текст ",
|
||||
"для проверки работы ",
|
||||
"функции разделения ",
|
||||
"сообщения",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Text with long words",
|
||||
text: "Этот текст содержит оченьдлинноеслово которое не должно быть разбито",
|
||||
maxLen: 20,
|
||||
expected: []string{
|
||||
"Этот текст содержит ",
|
||||
"оченьдлинноеслово ",
|
||||
"которое не должно ",
|
||||
"быть ",
|
||||
"разбито",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Text with multiple spaces",
|
||||
text: "Этот текст имеет много пробелов",
|
||||
maxLen: 20,
|
||||
expected: []string{
|
||||
"Этот текст ",
|
||||
"имеет много ",
|
||||
"пробелов",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Text with Russian characters and punctuation",
|
||||
text: "Привет! Как дела? Это тестовая строка для проверки работы функции.",
|
||||
maxLen: 25,
|
||||
expected: []string{
|
||||
"Привет! Как дела? Это ",
|
||||
"тестовая строка для ",
|
||||
"проверки работы ",
|
||||
"функции.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Single word longer than maxLen",
|
||||
text: "Некотороедлинноеслово",
|
||||
maxLen: 10,
|
||||
expected: []string{
|
||||
"Некотороед",
|
||||
"линноеслов",
|
||||
"о",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Text with mixed Russian and English",
|
||||
text: "Привет Hello мир World текст для проверки",
|
||||
maxLen: 20,
|
||||
expected: []string{
|
||||
"Привет Hello мир ",
|
||||
"World текст для ",
|
||||
"проверки",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Text with special characters",
|
||||
text: "Тест с символами: @#$%^&*()_+-=[]{}|;':\",./<>?",
|
||||
maxLen: 25,
|
||||
expected: []string{
|
||||
"Тест с символами: ",
|
||||
"@#$%^&*()_+-=[]{}|;':\",./",
|
||||
"<>?",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := sender.splitMessageByWords(tt.text, tt.maxLen)
|
||||
if len(result) != len(tt.expected) {
|
||||
t.Errorf("splitMessageByWords() length = %d, want %d", len(result), len(tt.expected))
|
||||
return
|
||||
}
|
||||
|
||||
for i, expectedPart := range tt.expected {
|
||||
if result[i] != expectedPart {
|
||||
t.Errorf("splitMessageByWords() part %d = %q, want %q", i, result[i], expectedPart)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user