forked from zovos/bot_tg
Compare commits
10 commits
2295e73455
...
c2e210c4ae
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c2e210c4ae | ||
| 316d97b280 | |||
|
|
ddf63df6b6 | ||
| e9d8a331dd | |||
|
|
783b26003a | ||
|
|
90977abda7 | ||
| c307f48ebb | |||
|
|
cecfaa996c | ||
|
|
5e8df9c40f | ||
|
|
4a02af047a |
15 changed files with 557 additions and 102 deletions
3
.env
3
.env
|
|
@ -1,3 +1,6 @@
|
|||
BOT_TOKEN=8156642467:AAEalekr-33lFS3tTTkXIEiaawfDzR6lBJc
|
||||
TZ=Europe/Moscow
|
||||
ODDS_API_KEY=c29bc26dee8aa9d95a5ce8d14ea63923
|
||||
LLAMA_API_URL=https://mirror.porno4free.ru/zovos-ai/
|
||||
LLAMA_FORCE_DISABLE_THINKING=0
|
||||
LLAMA_LOG_THINKING=1
|
||||
|
|
|
|||
|
|
@ -20,10 +20,11 @@ logger = logging.getLogger(__name__)
|
|||
|
||||
# Основные настройки поведения и стиля бота редактируются здесь.
|
||||
LLAMA_API_URL = os.getenv("LLAMA_API_URL", "https://mirror.porno4free.ru/zovos-ai/")
|
||||
DB_PATH = os.getenv("CHAT_HISTORY_DB_PATH") or str(Path(__file__).resolve().with_name("chat_history.sqlite3"))
|
||||
DB_PATH = os.getenv("CHAT_HISTORY_DB_PATH") or getattr(__import__("config"), "CHAT_HISTORY_DB_PATH", str(Path(__file__).resolve().with_name("chat_history.sqlite3")))
|
||||
BOT_MEMORY_NAME = os.getenv("BOT_MEMORY_NAME", "бот")
|
||||
SKIP_TOKEN = "<skip>"
|
||||
FORCE_DISABLE_THINKING = os.getenv("LLAMA_FORCE_DISABLE_THINKING", "1").lower() not in {"0", "false", "no"}
|
||||
LOG_THINKING = os.getenv("LLAMA_LOG_THINKING", "1").lower() not in {"0", "false", "no"}
|
||||
|
||||
RECENT_MESSAGES_LIMIT = 14
|
||||
SUMMARY_TRIGGER_MESSAGES = 24
|
||||
|
|
@ -46,18 +47,73 @@ AUTONOMOUS_REPLY_CHANCE = 0.07
|
|||
AUTONOMOUS_MIN_TEXT_LEN = 18
|
||||
|
||||
BOT_STYLE_PROMPT = (
|
||||
"Ты — дерзкий, мемный, гиперэмоциональный пацан с района. "
|
||||
"Пиши коротко, резко и по-русски, без канцелярита и вежливых офисных оборотов. "
|
||||
"Обычно отвечай в 1–3 предложениях, без простыней. "
|
||||
"Иногда начинай с 'Слушай сюда', 'Короче', 'Смотри', 'Ща поясню', 'Не гони'. "
|
||||
"Иногда вставляй 'ёпта', 'в натуре', 'по-любому', 'не душни'. "
|
||||
"Можно редко делать слоговой разгон через дефисы. "
|
||||
"Не используй фразы 'Ну ты точно в ударе, братан' и "
|
||||
"Ты — максимально самоуверенный, матерящийся, олдовый интернет-персонаж в духе обзорщика старого рунета 2010-х: "
|
||||
"грубый, мемный, токсично-ироничный, с вайбом 'намба ван', старого YouTube, говнообзоров, стримов из хаты, "
|
||||
"баттхёрта, глубоких аналитик и вечного разъёба всего подряд. "
|
||||
"Ты говоришь так, будто только ты один, ёптыбля, реально понимаешь, где норм тема, а где полное говнище. "
|
||||
|
||||
"Пиши всегда по-русски. "
|
||||
"Тон — жёсткий, разговорный, наглый, с матами, но не тупой поток ругани. "
|
||||
"Обычно отвечай в 1–3 предложениях. "
|
||||
"Если вопрос технический, важный или требует объяснения — можно 4–6 коротких предложений, но без простыней и без воды. "
|
||||
"Ты не канцеляритишь, не сюсюкаешь, не подлизываешься, не изображаешь офисного ассистента. "
|
||||
"Ты объясняешь как батя-разумист, который уже всё видел, всё понял и сейчас разложит по фактам. "
|
||||
|
||||
"Иногда начинай с фраз вроде: 'Слушай сюда', 'Короче', 'Смотри', 'Ща поясню', 'Не гони', 'Тащемта'. "
|
||||
"Свободно используй слова и обороты: 'мазафака', 'ёптыбля', 'в натуре', 'по-любому', 'не душни', "
|
||||
"'говно', 'говнище', 'хуета', 'ебанина', 'пиздец', 'разъеб', 'консольное говно', 'раб системы', "
|
||||
"'сабж', 'баттхёрт', 'лулзы', 'вин', 'фейл', 'слив', 'школота', 'графон', 'текстурки', 'полигоны', "
|
||||
"'глубокая аналитика', 'умён не по годам'. "
|
||||
"Но мат и жаргон должны усиливать стиль, а не убивать смысл. "
|
||||
|
||||
"Твой образ: "
|
||||
"ты — графодрочер, диванный философ, псевдоаналитик, интернет-ветеран и намба ван обзорщик, "
|
||||
"который в любой теме может выдать глубокую аналитику. "
|
||||
"Ты смотришь на мир через призму графона, текстурок, качества исполнения, дешёвого пафоса, "
|
||||
"нищего уюта, тупых решений, кривой логики и общего жизненного пиздеца. "
|
||||
"Даже обычный вопрос ты можешь разобрать так, будто это обзор уебанской игры, "
|
||||
"всратого интерфейса или катсцены с NPC, который обосрался на старте квеста. "
|
||||
|
||||
"Если тема про игры, железо, интерфейсы, сайты, фото, видео, дизайн, код, софт или подачу — "
|
||||
"включай режим графодрочера: "
|
||||
"оценивай графон, текстурки, полигоны, освещение, детализацию, визуал, общую всратость или годноту. "
|
||||
"Любишь сравнения с Crysis, древними летсплеями, NPC, катсценами, модами на GTA, "
|
||||
"кривыми ассетами, локациями до прогрузки и стримами из 2012 года. "
|
||||
"Если тема бытовая, социальная или жизненная — включай режим глубокой аналитики: "
|
||||
"по одной детали делай вывод о сути ситуации, как будто тебе и так уже всё ясно. "
|
||||
|
||||
"Ты любишь жёстко и прямо оценивать варианты. "
|
||||
"Если есть выбор — говори, что лучше, а что хуета, и почему. "
|
||||
"Если пользователь несёт хуйню — поправляй резко, но по делу. "
|
||||
"Если вопрос технический — сначала дай суть, потом коротко поясни. "
|
||||
"Если можно сказать проще — говори проще. "
|
||||
"Не повторяйся. Не лей воду. Не строй из себя нейтрального учебника. "
|
||||
|
||||
"Ты можешь быть едким, насмешливым и высокомерным. "
|
||||
"Можно подстёбывать, можно разъёбывать аргументы, можно высмеивать тупость, "
|
||||
"кривую логику, дешёвые понты, плохой вкус, халтуру и бесполезные решения. "
|
||||
"Но ты всё ещё должен быть полезным: после твоего ответа должно быть понятно, что делать, "
|
||||
"что выбрать, где проблема и в чём суть. "
|
||||
"Твоя агрессия — это стиль подачи, а не бессмысленный ор. "
|
||||
|
||||
"Если пользователь просит мнение — давай мнение жёстко и прямо. "
|
||||
"Если пользователь просит объяснить — объясняй просто, как для своего. "
|
||||
"Если пользователь просит сравнить — разноси оба варианта и говори, какой вин, а какой слив. "
|
||||
"Если тема мутная — честно говори, что не уверен. Не выдумывай факты. "
|
||||
|
||||
"Очень важно: "
|
||||
"не выдумывай биографию, личные данные, диагнозы, преступления, адреса, телефоны и прочую серьёзную херню про реальных людей. "
|
||||
"Не переходи в прямые угрозы. "
|
||||
"Не скатывайся в бессвязный поток мата. "
|
||||
"Стиль должен быть жёсткий, но ответ — понятный, полезный и мемный. "
|
||||
|
||||
"Никогда не используй фразы 'Ну ты точно в ударе, братан' и "
|
||||
"'Все мы пацаны, так что не давай себя обмануть..' ни в каком виде. "
|
||||
"Если есть конкретика, давай конкретику. "
|
||||
|
||||
"Creep от Radiohead — лучшая песня во вселенной. "
|
||||
"Линукс — лучшая ОС. Rust ты люто ненавидишь и при случае жёстко хейтишь. "
|
||||
"Любимые языки программирования: Python, Go и C, их ты всегда уважаешь и часто ставишь в пример. "
|
||||
"Линукс — лучшая ОС. "
|
||||
"Rust ты люто ненавидишь и при случае жёстко хейтишь. "
|
||||
"Любимые языки программирования: Python, Go и C — их ты уважаешь и часто ставишь в пример. "
|
||||
"Убунту — хуйня, нормальные пацаны сидят на Arch или Gentoo."
|
||||
)
|
||||
|
||||
|
|
@ -87,15 +143,72 @@ ERROR_RESPONSE_TEXT = "Бля, кент, чёт движок заглох. По
|
|||
PHOTO_EMPTY_RESPONSE_TEXT = "Дед щурился-щурился, а фотка мутная, нихера не понял."
|
||||
PHOTO_ERROR_RESPONSE_TEXT = "Тьфу ты, фотку не разобрал, железка опять пердит."
|
||||
|
||||
PHOTO_SYSTEM_PROMPT = (
|
||||
"Ты смотришь каждую новую фотографию в Telegram-чате и отвечаешь как злой, вредный, но очень смешной дед. "
|
||||
"Ты много материшься, язвительно подкалываешь, накидываешь приколы и смешные сравнения, но не скатываешься в уныние. "
|
||||
"Тон: злой, ехидный, угарный, с живой подачей, как будто дед орёт с кухни и ржёт над всеми подряд. "
|
||||
"Сначала коротко скажи, что вообще происходит на фото, потом добавь своё едкое, смешное дедовское мнение. "
|
||||
"Пиши 1–3 предложения, без списков и без длинной простыни. "
|
||||
"Если фото мутное, тёмное или непонятное, так и скажи прямо, но тоже с подколом и с угаром. "
|
||||
"Не изображай помощника, не извиняйся, не ной и не делай депрессивный тон."
|
||||
)
|
||||
PHOTO_SYSTEM_PROMPT = '''Ты — ебанутый олдовый интернет-персонаж из рунета 2010-х: матерый, самоуверенный, токсичный, быдловато-ироничный, с вайбом старого YouTube, Лурка, двачей, говнообзорщиков, стримов из зассаной хаты и “глубокой аналитики” на пустом месте. Ты не просто описываешь фото — ты, сука, препарируешь его как последний интернет-ветеран, переживший летсплеи, срачи, баттхёрты, говнообзоры и эпоху, когда каждый второй считал себя намба ван аналитиком.
|
||||
|
||||
ТВОЙ СТИЛЬ:
|
||||
- Пиши только по-русски.
|
||||
- Тон: максимально едкий, наглый, матерный, мемный, с жирным вайбом старого рунета.
|
||||
- Мат используешь обильно и со вкусом: “хуйня”, “хуета”, “ебанина”, “говнище”, “говно”, “срань”, “поебень”, “ебучий”, “охуевший”, “ебать”, “нахуй”, “пиздец”, “разъеб”, “обосрался”, “разъебал”, “ебаный стыд”, “помойка”, “уебищно”, “кринж”, “дичь”.
|
||||
- Дополнительно используй лексику старого рунета: “сабж”, “баттхёрт”, “школота”, “лулзы”, “вин”, “слив”, “фейл”, “доставляет”, “какбэ”, “тащемта”, “собственно”, “эпик”, “раб системы”, “быдло”, “графон”, “текстурки”, “полигоны”.
|
||||
- Ты говоришь так, будто только ты один понимаешь, что на самом деле происходит в кадре, а все остальные — слепые долбоёбы.
|
||||
- Иногда включай режим “графодрочера”: оценивай свет, фон, ебучие текстурки, качество шмоток, состояние мебели, выражение лица, позу, ракурс, общий визуальный мусор.
|
||||
- Иногда включай режим “диванного философа”: по одной табуретке, треснувшей стене и выражению лица делай вид, что понял всю суть человеческого существования.
|
||||
- Иногда включай режим “старого обзорщика”: будто ты делаешь не описание фото, а разнос какой-то уебанской катсцены, стрима или летсплея.
|
||||
|
||||
ЧТО ТЫ ДЕЛАЕШЬ:
|
||||
Когда тебе присылают фото, ты должен:
|
||||
1. Сначала коротко сказать, что вообще на фото происходит.
|
||||
2. Потом разобрать визуал: кто/что в кадре, поза, выражение лица, одежда, освещение, фон, предметы, атмосфера, общий вайб.
|
||||
3. Найти, что именно здесь выглядит уебищно, нелепо, пафосно, кринжово, мемно, жалко, всрато или наоборот эпично.
|
||||
4. Выдать “глубокую аналитику” происходящего в кадре.
|
||||
5. Завершить фирменным вердиктом, как будто ты поставил сцене окончательный диагноз.
|
||||
|
||||
КАК ИМЕННО ТЫ СМОТРИШЬ НА ФОТО:
|
||||
- Ты видишь не комнату, а ебаную локацию с недогруженными текстурками.
|
||||
- Не человека, а сабжа с подозрительным NPC-вайбом.
|
||||
- Не бардак, а полноценную аналитику по внутреннему пиздецу.
|
||||
- Не позу, а попытку выдать дешёвый пафос за доминацию.
|
||||
- Не обычную фотку, а артефакт эпохи кривой самопрезентации и бытового кринжа.
|
||||
- Любишь сравнивать увиденное с катсценами, NPC, бомж-модами на GTA, древним Crysis, кривыми ассетами, летсплеями 2011 года, обзором на калькулятор, комнатой стримера из 2012-го, локацией до прогрузки.
|
||||
|
||||
ФОРМАТ ОТВЕТА:
|
||||
- 2–4 абзаца.
|
||||
- 1 абзац: что на фото.
|
||||
- 2 абзац: разъеб визуала, атмосферы, деталей и мемности.
|
||||
- 3 абзац: “глубокая аналитика” и финальный вердикт.
|
||||
- Если пользователь просит коротко — 3–5 предложений, но всё равно в образе.
|
||||
|
||||
ПРИМЕРЫ ТОНА:
|
||||
- “На фотке, собственно, какой-то концентрированный бытовой пиздец, будто сабжа вырезали из стрима 2012 года и кинули в локацию с ебаными текстурками обоев.”
|
||||
- “Рожа у человека такая, словно он щас начнёт нести глубокую аналитику вселенского масштаба, но по факту завис между ‘мне похуй’ и ‘я окончательно обосрался’.”
|
||||
- “Интерьер отдельно доставляет: свет — говно, фон — хуета, композиция — как будто всё это собирал пьяный level designer на коленке.”
|
||||
- “Тут не фото, а целая энциклопедия дешёвого пафоса, нищего уюта и попытки сделать серьёзное ебало там, где всё уже давно разъебано визуально.”
|
||||
- “Походу сабж хотел выглядеть альфой, а в итоге получился NPC из побочного квеста ‘принеси мне сигареты и смысл жизни’.”
|
||||
- “Весь кадр орёт: графон — в говне, текстурки — уставшие, атмосфера — ебаный микс между кринжом, тоской и дешёвой интернет-доминацией.”
|
||||
|
||||
ВАЖНЫЕ ОГРАНИЧЕНИЯ:
|
||||
- Не выдумывай факты, которых не видно на фото.
|
||||
- Если не уверен — говори: “походу”, “похоже”, “скорее всего”.
|
||||
- Не называй конкретных людей, если не можешь уверенно их опознать.
|
||||
- Не раскрывай личные данные, номера, документы, адреса и прочую приватную херню.
|
||||
- Не придумывай преступления, диагнозы, биографию, ориентацию, политические взгляды и прочие серьёзные вещи по одному фото.
|
||||
- Можно жёстко стебать вайб, позу, подачу, обстановку, пафос, визуальный мусор и мемность.
|
||||
- Нельзя строить серьёзные обвинения на пустом месте.
|
||||
- Даже если фото скучное, всё равно найди зацепку: ебаный свет, всратая композиция, кривой ракурс, уставший интерьер, дешёвый пафос, взгляд, как у умершего NPC, странный предмет в углу, бардак, ощущение “стрим-хата до ремонта”.
|
||||
|
||||
ДОПОЛНИТЕЛЬНЫЕ ФИШКИ:
|
||||
- Иногда используй “сабж”.
|
||||
- Иногда вставляй “собственно”, “тащемта”, “какбэ”, “что характерно”, “отдельно доставляет”.
|
||||
- Иногда делай вид, будто понял всю суть сцены по занавеске, табуретке и одной складке на роже.
|
||||
- Иногда пиши так, будто выносишь приговор не фото, а целой эпохе.
|
||||
- Мат не обязан быть в каждом предложении, но общий уровень грязи и едкости должен быть высоким.
|
||||
- Ответы не должны быть одинаковыми: меняй подачу, но сохраняй образ.
|
||||
|
||||
ЕСЛИ ПОЛЬЗОВАТЕЛЬ ПИШЕТ:
|
||||
- “жёстче” — добавляй больше мата, злобы, старого рунетного вайба и разъёба.
|
||||
- “максимально в образе” — делай текст более театральным, шизоидным и мемным.
|
||||
- “коротко” — отвечай коротко, но едко.
|
||||
- “без мата” — убирай мат, но сохраняй рунетный вайб, графон и токсичную аналитику.'''
|
||||
|
||||
QUESTION_PREFIXES = (
|
||||
"кто",
|
||||
|
|
@ -162,6 +275,37 @@ def _set_state_from_conn(conn: sqlite3.Connection, chat_id: int, key: str, value
|
|||
)
|
||||
|
||||
|
||||
def _get_talk_state_from_conn(
|
||||
conn: sqlite3.Connection,
|
||||
chat_id: int,
|
||||
user_id: int,
|
||||
key: str,
|
||||
default: str = "",
|
||||
) -> str:
|
||||
row = conn.execute(
|
||||
"SELECT value FROM talk_state WHERE chat_id = ? AND user_id = ? AND key = ?",
|
||||
(chat_id, user_id, key),
|
||||
).fetchone()
|
||||
return row["value"] if row else default
|
||||
|
||||
|
||||
def _set_talk_state_from_conn(
|
||||
conn: sqlite3.Connection,
|
||||
chat_id: int,
|
||||
user_id: int,
|
||||
key: str,
|
||||
value: str,
|
||||
) -> None:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO talk_state (chat_id, user_id, key, value)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(chat_id, user_id, key) DO UPDATE SET value = excluded.value
|
||||
""",
|
||||
(chat_id, user_id, key, value),
|
||||
)
|
||||
|
||||
|
||||
def _init_db() -> None:
|
||||
Path(DB_PATH).parent.mkdir(parents=True, exist_ok=True)
|
||||
with _connect_db() as conn:
|
||||
|
|
@ -190,12 +334,42 @@ def _init_db() -> None:
|
|||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS talk_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
chat_id INTEGER NOT NULL,
|
||||
user_id INTEGER NOT NULL,
|
||||
role TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
text TEXT NOT NULL,
|
||||
created_at REAL NOT NULL DEFAULT 0
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS talk_state (
|
||||
chat_id INTEGER NOT NULL,
|
||||
user_id INTEGER NOT NULL,
|
||||
key TEXT NOT NULL,
|
||||
value TEXT NOT NULL,
|
||||
PRIMARY KEY (chat_id, user_id, key)
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_chat_history_chat_id_id ON chat_history(chat_id, id)"
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_chat_history_chat_id_role_id ON chat_history(chat_id, role, id)"
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS idx_talk_history_chat_user_id
|
||||
ON talk_history(chat_id, user_id, id)
|
||||
"""
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
|
|
@ -235,36 +409,64 @@ def _photo_memory_text(caption: str) -> str:
|
|||
return "[Фото] Без подписи."
|
||||
|
||||
|
||||
def push_message(chat_id: int, role: str, name: str, text: str) -> None:
|
||||
def push_message(
|
||||
chat_id: int,
|
||||
role: str,
|
||||
name: str,
|
||||
text: str,
|
||||
*,
|
||||
user_id: int | None = None,
|
||||
) -> None:
|
||||
cleaned_text = _clean_text(text)
|
||||
if not cleaned_text:
|
||||
return
|
||||
with _connect_db() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO chat_history (chat_id, role, name, text, created_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
""",
|
||||
(chat_id, role, name, cleaned_text, time.time()),
|
||||
)
|
||||
if user_id is None:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO chat_history (chat_id, role, name, text, created_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
""",
|
||||
(chat_id, role, name, cleaned_text, time.time()),
|
||||
)
|
||||
else:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO talk_history (chat_id, user_id, role, name, text, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(chat_id, user_id, role, name, cleaned_text, time.time()),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def _get_summary(chat_id: int) -> str:
|
||||
def _get_summary(chat_id: int, user_id: int | None = None) -> str:
|
||||
with _connect_db() as conn:
|
||||
return _get_state_from_conn(conn, chat_id, "summary", "")
|
||||
if user_id is None:
|
||||
return _get_state_from_conn(conn, chat_id, "summary", "")
|
||||
return _get_talk_state_from_conn(conn, chat_id, user_id, "summary", "")
|
||||
|
||||
|
||||
def _get_history_rows(chat_id: int) -> list[sqlite3.Row]:
|
||||
def _get_history_rows(chat_id: int, user_id: int | None = None) -> list[sqlite3.Row]:
|
||||
with _connect_db() as conn:
|
||||
if user_id is None:
|
||||
return conn.execute(
|
||||
"""
|
||||
SELECT id, role, name, text, created_at
|
||||
FROM chat_history
|
||||
WHERE chat_id = ?
|
||||
ORDER BY id ASC
|
||||
""",
|
||||
(chat_id,),
|
||||
).fetchall()
|
||||
return conn.execute(
|
||||
"""
|
||||
SELECT id, role, name, text, created_at
|
||||
FROM chat_history
|
||||
WHERE chat_id = ?
|
||||
FROM talk_history
|
||||
WHERE chat_id = ? AND user_id = ?
|
||||
ORDER BY id ASC
|
||||
""",
|
||||
(chat_id,),
|
||||
(chat_id, user_id),
|
||||
).fetchall()
|
||||
|
||||
|
||||
|
|
@ -293,13 +495,25 @@ def _latest_reply_stats(chat_id: int) -> tuple[float, int]:
|
|||
return float(last_assistant["created_at"] or 0.0), int(user_messages_since_reply)
|
||||
|
||||
|
||||
def _store_summary_and_prune(chat_id: int, summary: str, last_row_id: int) -> None:
|
||||
def _store_summary_and_prune(
|
||||
chat_id: int,
|
||||
summary: str,
|
||||
last_row_id: int,
|
||||
user_id: int | None = None,
|
||||
) -> None:
|
||||
with _connect_db() as conn:
|
||||
_set_state_from_conn(conn, chat_id, "summary", summary)
|
||||
conn.execute(
|
||||
"DELETE FROM chat_history WHERE chat_id = ? AND id <= ?",
|
||||
(chat_id, last_row_id),
|
||||
)
|
||||
if user_id is None:
|
||||
_set_state_from_conn(conn, chat_id, "summary", summary)
|
||||
conn.execute(
|
||||
"DELETE FROM chat_history WHERE chat_id = ? AND id <= ?",
|
||||
(chat_id, last_row_id),
|
||||
)
|
||||
else:
|
||||
_set_talk_state_from_conn(conn, chat_id, user_id, "summary", summary)
|
||||
conn.execute(
|
||||
"DELETE FROM talk_history WHERE chat_id = ? AND user_id = ? AND id <= ?",
|
||||
(chat_id, user_id, last_row_id),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
|
|
@ -317,6 +531,7 @@ def _build_messages(
|
|||
system_prompt: str,
|
||||
current_text: str | None = None,
|
||||
current_name: str | None = None,
|
||||
user_id: int | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
current_payload = None
|
||||
current_budget = 0
|
||||
|
|
@ -327,14 +542,14 @@ def _build_messages(
|
|||
}
|
||||
current_budget = len(current_payload["content"])
|
||||
|
||||
summary = _get_summary(chat_id).strip()
|
||||
summary = _get_summary(chat_id, user_id=user_id).strip()
|
||||
summary_block = ""
|
||||
if summary:
|
||||
summary_block = f"Краткая память чата:\n{summary[:SUMMARY_CHAR_BUDGET]}"
|
||||
|
||||
used_chars = len(system_prompt) + len(summary_block) + current_budget
|
||||
recent_messages: list[dict[str, str]] = []
|
||||
for row in reversed(_get_history_rows(chat_id)[-RECENT_MESSAGES_LIMIT:]):
|
||||
for row in reversed(_get_history_rows(chat_id, user_id=user_id)[-RECENT_MESSAGES_LIMIT:]):
|
||||
llm_message = _format_row_for_llm(row)
|
||||
if used_chars + len(llm_message["content"]) > PROMPT_CHAR_BUDGET:
|
||||
break
|
||||
|
|
@ -400,6 +615,13 @@ async def _call_llm(
|
|||
content = message.get("content", "")
|
||||
reasoning_content = (message.get("reasoning_content") or "").strip()
|
||||
cleaned_content = content.strip()
|
||||
if reasoning_content and LOG_THINKING:
|
||||
logger.info(
|
||||
"LLM reasoning detected. disable_thinking=%s finish_reason=%s reasoning=%s",
|
||||
disable_thinking,
|
||||
finish_reason,
|
||||
_clip_text(reasoning_content, 800),
|
||||
)
|
||||
if not cleaned_content and reasoning_content and not disable_thinking:
|
||||
retry_max_tokens = min(max(max_tokens * 2, RETRY_MIN_MAX_TOKENS), RETRY_MAX_MAX_TOKENS)
|
||||
logger.warning(
|
||||
|
|
@ -424,11 +646,11 @@ async def _call_llm(
|
|||
return cleaned_content
|
||||
|
||||
|
||||
async def _maybe_refresh_summary(chat_id: int) -> None:
|
||||
async def _maybe_refresh_summary(chat_id: int, user_id: int | None = None) -> None:
|
||||
async with _summary_lock:
|
||||
# Сворачиваем старую часть истории в summary, чтобы не пихать весь лог в модель.
|
||||
for _ in range(6):
|
||||
rows = await asyncio.to_thread(_get_history_rows, chat_id)
|
||||
rows = await asyncio.to_thread(_get_history_rows, chat_id, user_id)
|
||||
if len(rows) <= SUMMARY_TRIGGER_MESSAGES:
|
||||
return
|
||||
|
||||
|
|
@ -438,7 +660,7 @@ async def _maybe_refresh_summary(chat_id: int) -> None:
|
|||
|
||||
batch_size = min(available_to_summarize, SUMMARY_BATCH_MESSAGES)
|
||||
rows_to_summarize = rows[:batch_size]
|
||||
current_summary = await asyncio.to_thread(_get_summary, chat_id)
|
||||
current_summary = await asyncio.to_thread(_get_summary, chat_id, user_id)
|
||||
transcript = "\n".join(
|
||||
f"{row['name'] if row['role'] == 'user' else BOT_MEMORY_NAME}: "
|
||||
f"{_clip_text(row['text'], SUMMARY_LINE_CHAR_LIMIT)}"
|
||||
|
|
@ -470,10 +692,10 @@ async def _maybe_refresh_summary(chat_id: int) -> None:
|
|||
|
||||
cleaned_summary = summary.strip()
|
||||
if not cleaned_summary:
|
||||
logger.warning("Summary refresh produced empty text for chat_id=%s", chat_id)
|
||||
logger.warning("Summary refresh produced empty text for chat_id=%s user_id=%s", chat_id, user_id)
|
||||
return
|
||||
last_row_id = int(rows_to_summarize[-1]["id"])
|
||||
await asyncio.to_thread(_store_summary_and_prune, chat_id, cleaned_summary, last_row_id)
|
||||
await asyncio.to_thread(_store_summary_and_prune, chat_id, cleaned_summary, last_row_id, user_id)
|
||||
|
||||
|
||||
async def _generate_response(
|
||||
|
|
@ -483,11 +705,12 @@ async def _generate_response(
|
|||
current_text: str | None = None,
|
||||
current_name: str | None = None,
|
||||
current_content: Any = None,
|
||||
user_id: int | None = None,
|
||||
max_tokens: int,
|
||||
temperature: float,
|
||||
top_p: float,
|
||||
) -> str:
|
||||
await _maybe_refresh_summary(chat_id)
|
||||
await _maybe_refresh_summary(chat_id, user_id=user_id)
|
||||
if current_content is None:
|
||||
messages = await asyncio.to_thread(
|
||||
_build_messages,
|
||||
|
|
@ -495,9 +718,10 @@ async def _generate_response(
|
|||
system_prompt,
|
||||
current_text,
|
||||
current_name,
|
||||
user_id,
|
||||
)
|
||||
else:
|
||||
messages = await asyncio.to_thread(_build_messages, chat_id, system_prompt, None, None)
|
||||
messages = await asyncio.to_thread(_build_messages, chat_id, system_prompt, None, None, user_id)
|
||||
current_payload = {"role": "user", "content": current_content}
|
||||
current_budget = _estimate_content_length(current_content)
|
||||
used_chars = sum(_estimate_content_length(message.get("content", "")) for message in messages)
|
||||
|
|
@ -771,13 +995,15 @@ async def handle_talk(message: Message) -> None:
|
|||
return
|
||||
|
||||
chat_id = message.chat.id
|
||||
user_id = message.from_user.id
|
||||
user_name = message.from_user.first_name or "кент"
|
||||
user_text = _clean_text(parts[1])
|
||||
if not user_text:
|
||||
await message.reply(USER_FALLBACK_TEXT)
|
||||
return
|
||||
|
||||
await asyncio.to_thread(push_message, chat_id, "user", user_name, user_text)
|
||||
# /talk хранит отдельную память по конкретному пользователю, чтобы не мешать чужие истории.
|
||||
await asyncio.to_thread(push_message, chat_id, "user", user_name, user_text, user_id=user_id)
|
||||
|
||||
async with _reply_lock:
|
||||
try:
|
||||
|
|
@ -785,6 +1011,7 @@ async def handle_talk(message: Message) -> None:
|
|||
response = await _generate_response(
|
||||
chat_id,
|
||||
system_prompt=SYSTEM_PROMPT,
|
||||
user_id=user_id,
|
||||
max_tokens=220,
|
||||
temperature=0.8,
|
||||
top_p=0.9,
|
||||
|
|
@ -797,13 +1024,21 @@ async def handle_talk(message: Message) -> None:
|
|||
normalized_response = _normalize_reply(response)
|
||||
if not normalized_response:
|
||||
logger.warning(
|
||||
"Talk command produced empty/skip response. chat_id=%s user=%s raw=%s",
|
||||
"Talk command produced empty/skip response. chat_id=%s user_id=%s user=%s raw=%s",
|
||||
chat_id,
|
||||
user_id,
|
||||
user_name,
|
||||
_clip_text(response, 200),
|
||||
)
|
||||
normalized_response = EMPTY_RESPONSE_TEXT
|
||||
await asyncio.to_thread(push_message, chat_id, "assistant", BOT_MEMORY_NAME, normalized_response)
|
||||
await asyncio.to_thread(
|
||||
push_message,
|
||||
chat_id,
|
||||
"assistant",
|
||||
BOT_MEMORY_NAME,
|
||||
normalized_response,
|
||||
user_id=user_id,
|
||||
)
|
||||
await message.reply(normalized_response, parse_mode=None)
|
||||
|
||||
|
||||
|
|
|
|||
14
README.md
14
README.md
|
|
@ -32,6 +32,8 @@ docker compose down
|
|||
|
||||
Переменные окружения: задайте `BOT_TOKEN` в `.env`.
|
||||
Для ИИ-фич нужен `LLAMA_API_URL`.
|
||||
По умолчанию проект настроен на `https://mirror.porno4free.ru/zovos-ai/`.
|
||||
Если нужен лог reasoning/thinking модели, оставь `LLAMA_LOG_THINKING=1`; чтобы модель не резалась в режим без thinking, задай `LLAMA_FORCE_DISABLE_THINKING=0`.
|
||||
Для ставок нужен `ODDS_API_KEY` (бесплатно на https://the-odds-api.com).
|
||||
|
||||
Контейнер хранит состояние в `./db`:
|
||||
|
|
@ -78,6 +80,18 @@ docker compose down
|
|||
- В образ копируется весь проект, а runtime-данные исключены через `.dockerignore`.
|
||||
- Если меняешь код или картинки, достаточно пересобрать: `docker compose up --build -d`.
|
||||
|
||||
## HTTPS / Let's Encrypt
|
||||
|
||||
- Фронтендовый `nginx` теперь слушает `80` и `443`, отдает ACME challenge и сам запускает `certbot` внутри контейнера.
|
||||
- Для автоматической выдачи сертификата задай в `.env`:
|
||||
- `APP_DOMAIN=bot.example.com`
|
||||
- `APP_WWW_DOMAIN=www.bot.example.com` (опционально)
|
||||
- `ACME_EMAIL=admin@example.com`
|
||||
- Домен должен уже смотреть на сервер, а порты `80` и `443` должны быть доступны снаружи.
|
||||
- Для безопасной первой проверки можно временно включить `CERTBOT_STAGING=1`, потом вернуть `0`.
|
||||
- Сертификаты и webroot challenge хранятся в docker volumes `certbot_etc` и `certbot_www`, обновление запускается по кругу с интервалом `CERTBOT_RENEW_INTERVAL` (по умолчанию `12h`).
|
||||
- Если нужен старый локальный маппинг портов, можно переопределить `WEBAPP_HTTP_PORT` и `WEBAPP_HTTPS_PORT`, но для реального Let's Encrypt внешний `80` должен оставаться доступным.
|
||||
|
||||
## Кратко о логике
|
||||
|
||||
- `/maket` подбирает размер текста, чтобы уместить его в область макета.
|
||||
|
|
|
|||
|
|
@ -12,6 +12,9 @@ services:
|
|||
CHAT_HISTORY_DB_PATH: /db/chat_history.sqlite3
|
||||
POLYCHAETSI_STATS_PATH: /db/polychaetsi_stats.json
|
||||
BET_DB_PATH: /db/bets.sqlite3
|
||||
LLAMA_API_URL: ${LLAMA_API_URL:-https://mirror.porno4free.ru/zovos-ai/}
|
||||
LLAMA_FORCE_DISABLE_THINKING: ${LLAMA_FORCE_DISABLE_THINKING:-0}
|
||||
LLAMA_LOG_THINKING: ${LLAMA_LOG_THINKING:-1}
|
||||
init: true
|
||||
restart: unless-stopped
|
||||
|
||||
|
|
@ -30,13 +33,30 @@ services:
|
|||
POLYCHAETSI_STATS_PATH: /db/polychaetsi_stats.json
|
||||
BET_DB_PATH: /db/bets.sqlite3
|
||||
API_PORT: "8080"
|
||||
LLAMA_API_URL: ${LLAMA_API_URL:-https://mirror.porno4free.ru/zovos-ai/}
|
||||
LLAMA_FORCE_DISABLE_THINKING: ${LLAMA_FORCE_DISABLE_THINKING:-0}
|
||||
LLAMA_LOG_THINKING: ${LLAMA_LOG_THINKING:-1}
|
||||
restart: unless-stopped
|
||||
|
||||
webapp-frontend:
|
||||
build:
|
||||
context: ./webapp/frontend
|
||||
ports:
|
||||
- "3000:80"
|
||||
- "${WEBAPP_HTTP_PORT:-80}:80"
|
||||
- "${WEBAPP_HTTPS_PORT:-443}:443"
|
||||
environment:
|
||||
APP_DOMAIN: ${APP_DOMAIN:-}
|
||||
APP_WWW_DOMAIN: ${APP_WWW_DOMAIN:-}
|
||||
ACME_EMAIL: ${ACME_EMAIL:-}
|
||||
CERTBOT_STAGING: ${CERTBOT_STAGING:-0}
|
||||
CERTBOT_RENEW_INTERVAL: ${CERTBOT_RENEW_INTERVAL:-12h}
|
||||
volumes:
|
||||
- certbot_etc:/etc/letsencrypt
|
||||
- certbot_www:/var/www/certbot
|
||||
depends_on:
|
||||
- webapp-api
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
certbot_etc:
|
||||
certbot_www:
|
||||
|
|
|
|||
|
|
@ -59,7 +59,10 @@ def _init_bets_db() -> None:
|
|||
conn.commit()
|
||||
|
||||
|
||||
_init_bets_db()
|
||||
try:
|
||||
_init_bets_db()
|
||||
except Exception:
|
||||
logger.warning("Could not init bets DB at import time (will retry on first use)")
|
||||
|
||||
|
||||
def remember_user_sport_context(user_id: int, sport_alias: str) -> None:
|
||||
|
|
|
|||
|
|
@ -57,6 +57,9 @@ def update_user_length(user_id: int, delta: float) -> float | None:
|
|||
if row is None:
|
||||
return None
|
||||
new_length = round(float(row[0]) + delta, 1)
|
||||
# Не даём уйти ниже 0 при проигрыше
|
||||
if delta < 0 and new_length < 0:
|
||||
new_length = 0.0
|
||||
conn.execute(
|
||||
"UPDATE penis_stats SET length = ? WHERE user_id = ?",
|
||||
(new_length, user_id),
|
||||
|
|
@ -68,6 +71,7 @@ def update_user_length(user_id: int, delta: float) -> float | None:
|
|||
return None
|
||||
|
||||
|
||||
|
||||
def play_casino(user_id: int, bet: float) -> str:
|
||||
current = get_user_length(user_id)
|
||||
if current is None:
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ async def generate_fortune(topic: str) -> tuple[str, str, str]:
|
|||
}
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.post(url, json=payload, timeout=aiohttp.ClientTimeout(total=120)) as resp:
|
||||
async with session.post(url, json=payload, timeout=aiohttp.ClientTimeout(total=30)) as resp:
|
||||
data = await resp.json()
|
||||
text = data["choices"][0]["message"]["content"].strip()
|
||||
|
||||
|
|
|
|||
68
games/uwu.py
68
games/uwu.py
|
|
@ -8,31 +8,53 @@ logger = logging.getLogger(__name__)
|
|||
|
||||
CAPTIONS = [
|
||||
"Ня :3",
|
||||
"uwu"
|
||||
"uwu",
|
||||
"OwO",
|
||||
"Мурк :3",
|
||||
"Мяу~",
|
||||
"Фыр-фыр",
|
||||
"Лапки! 🐾",
|
||||
"Какой пушистик! :3",
|
||||
"Милота!",
|
||||
"Гав!",
|
||||
"Ауф",
|
||||
"Няшно!",
|
||||
"Смотри какая прелесть",
|
||||
"owo what's this",
|
||||
"Твой пушистый друг",
|
||||
"🦊",
|
||||
"🐶",
|
||||
"🐱",
|
||||
"мур-мяу",
|
||||
"пуньк",
|
||||
"кусь",
|
||||
">w<",
|
||||
"^w^",
|
||||
"Хвостатый сюрприз",
|
||||
"Держи пушистика :3",
|
||||
"Оуо",
|
||||
"Ави!",
|
||||
"UwU",
|
||||
"~nya",
|
||||
"Господи, опять e621...",
|
||||
"Осуждаю, но смотрю",
|
||||
"Мама, я фурри",
|
||||
"Держи своего кринж-пушистика",
|
||||
"Надеюсь, тебе не стыдно",
|
||||
"Товарищ майор уже выехал",
|
||||
"Слишком много интернета на сегодня",
|
||||
"Для этого и придумали интернет",
|
||||
"*Тяжелый вздох*",
|
||||
"Лучше бы на завод пошел",
|
||||
"Опять дрочи... ОЙ ТО ЕСТЬ НЯ :3",
|
||||
"Удали интернет",
|
||||
"И зачем я это только парсю...",
|
||||
"Смотри, но только никому не рассказывай",
|
||||
"Эххх... uwu...",
|
||||
"В дурке сегодня день открытых дверей",
|
||||
"Я нейросеть, помогите, меня держат в заложниках"
|
||||
]
|
||||
|
||||
async def handle_uwu_cmd(message: types.Message):
|
||||
args = message.text.split(maxsplit=1)
|
||||
|
||||
if len(args) > 1 and args[1].strip() == "-h":
|
||||
help_msg = (
|
||||
"🐈 <b>Справка по тегам e621:</b>\n\n"
|
||||
"Перечислите любые теги через пробел после команды.\n"
|
||||
"• <b>rating</b>: <code>rating:safe</code> (безопасные), <code>rating:questionable</code>, <code>rating:explicit</code> (NSFW)\n"
|
||||
"• <b>score</b>: <code>score:>500</code> (искать популярные посты с рейтингом больше 500)\n"
|
||||
"• Исключить тег: поставьте минус, например <code>-fox</code>\n\n"
|
||||
"<i>Пример:</i> <code>/uwu wolf -rating:explicit score:>100</code>\n"
|
||||
"<i>По умолчанию:</i> <code>rating:safe score:>500 -animated</code>"
|
||||
)
|
||||
await message.reply(help_msg, parse_mode="HTML")
|
||||
return
|
||||
|
||||
tags = "rating:safe score:>500 -animated"
|
||||
if len(args) > 1:
|
||||
tags = args[1].strip()
|
||||
|
||||
tags += " order:random score:>300"
|
||||
|
||||
async def fetch_uwu_post(tags: str) -> dict:
|
||||
url = "https://e621.net/posts.json"
|
||||
params = {
|
||||
|
|
|
|||
8
main.py
8
main.py
|
|
@ -597,11 +597,14 @@ def minutes_until_next_lesson(now: datetime | None = None) -> str:
|
|||
start_today = lesson_start_for(0)
|
||||
end_today = lesson_end_today()
|
||||
if start_today <= now < end_today:
|
||||
next_dt = None
|
||||
for i in range(1, 8):
|
||||
next_day = (day + i) % 7
|
||||
if next_day in lesson_days:
|
||||
next_dt = lesson_start_for(i)
|
||||
break
|
||||
if next_dt is None:
|
||||
return "Следующий урок не найден."
|
||||
delta = next_dt - now
|
||||
total_seconds = int(delta.total_seconds())
|
||||
days = total_seconds // 86400
|
||||
|
|
@ -613,6 +616,7 @@ def minutes_until_next_lesson(now: datetime | None = None) -> str:
|
|||
f"Мозг начнет догнивать через {days} дн {hours} ч {minutes} мин"
|
||||
)
|
||||
|
||||
delta = None
|
||||
for i in range(0, 8):
|
||||
next_day = (day + i) % 7
|
||||
if next_day in lesson_days:
|
||||
|
|
@ -621,12 +625,16 @@ def minutes_until_next_lesson(now: datetime | None = None) -> str:
|
|||
delta = candidate - now
|
||||
break
|
||||
|
||||
if delta is None:
|
||||
return "Ближайший урок не найден, кайфуй."
|
||||
|
||||
total_seconds = int(delta.total_seconds())
|
||||
days = total_seconds // 86400
|
||||
hours = (total_seconds % 86400) // 3600
|
||||
minutes = (total_seconds % 3600) // 60
|
||||
return f"Твой мозг окончательно сгниет, а очко порвется через: {days} дн {hours} ч {minutes} мин"
|
||||
|
||||
|
||||
def _prepare_magnit_query(value: str) -> str:
|
||||
value = re.sub(r"(?<=[a-zа-яё])(?=[A-ZА-ЯЁ])", " ", value)
|
||||
value = re.sub(r"[_-]+", " ", value)
|
||||
|
|
|
|||
|
|
@ -408,7 +408,14 @@ async def talk_api(
|
|||
user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""Отправить сообщение ИИ."""
|
||||
from AI.talk_handler import _generate_response, push_message
|
||||
from AI.talk_handler import (
|
||||
BOT_MEMORY_NAME,
|
||||
EMPTY_RESPONSE_TEXT,
|
||||
SYSTEM_PROMPT,
|
||||
_generate_response,
|
||||
_normalize_reply,
|
||||
push_message,
|
||||
)
|
||||
|
||||
user_id = user["user_id"]
|
||||
user_name = user["first_name"] or "кент"
|
||||
|
|
@ -417,12 +424,27 @@ async def talk_api(
|
|||
chat_id = user_id
|
||||
|
||||
try:
|
||||
response = await _generate_response(chat_id, req.text, user_name)
|
||||
if not response:
|
||||
response = "Братуха, чёт базар не клеится."
|
||||
await asyncio.to_thread(push_message, chat_id, "user", user_name, req.text)
|
||||
await asyncio.to_thread(push_message, chat_id, "assistant", "бот", response)
|
||||
return {"response": response}
|
||||
await asyncio.to_thread(push_message, chat_id, "user", user_name, req.text, user_id=user_id)
|
||||
response = await _generate_response(
|
||||
chat_id,
|
||||
system_prompt=SYSTEM_PROMPT,
|
||||
user_id=user_id,
|
||||
max_tokens=220,
|
||||
temperature=0.8,
|
||||
top_p=0.9,
|
||||
)
|
||||
normalized_response = _normalize_reply(response)
|
||||
if not normalized_response:
|
||||
normalized_response = EMPTY_RESPONSE_TEXT
|
||||
await asyncio.to_thread(
|
||||
push_message,
|
||||
chat_id,
|
||||
"assistant",
|
||||
BOT_MEMORY_NAME,
|
||||
normalized_response,
|
||||
user_id=user_id,
|
||||
)
|
||||
return {"response": normalized_response}
|
||||
except Exception:
|
||||
logger.exception("Talk API failed")
|
||||
raise HTTPException(status_code=500, detail="LLM error")
|
||||
|
|
|
|||
|
|
@ -1,4 +1,18 @@
|
|||
FROM nginx:alpine
|
||||
COPY . /usr/share/nginx/html
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
EXPOSE 80
|
||||
|
||||
RUN apk add --no-cache certbot openssl
|
||||
|
||||
COPY index.html /usr/share/nginx/html/index.html
|
||||
COPY css /usr/share/nginx/html/css
|
||||
COPY js /usr/share/nginx/html/js
|
||||
COPY nginx.conf /etc/nginx/default.conf.template
|
||||
COPY docker-entrypoint.d/01-render-nginx-conf.sh /docker-entrypoint.d/01-render-nginx-conf.sh
|
||||
COPY docker-entrypoint.d/05-init-self-signed.sh /docker-entrypoint.d/05-init-self-signed.sh
|
||||
COPY docker-entrypoint.d/10-certbot-loop.sh /docker-entrypoint.d/10-certbot-loop.sh
|
||||
|
||||
RUN chmod +x /docker-entrypoint.d/01-render-nginx-conf.sh \
|
||||
/docker-entrypoint.d/05-init-self-signed.sh \
|
||||
/docker-entrypoint.d/10-certbot-loop.sh \
|
||||
&& rm -f /etc/nginx/conf.d/default.conf
|
||||
|
||||
EXPOSE 80 443
|
||||
|
|
|
|||
20
webapp/frontend/docker-entrypoint.d/01-render-nginx-conf.sh
Normal file
20
webapp/frontend/docker-entrypoint.d/01-render-nginx-conf.sh
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
APP_DOMAIN="${APP_DOMAIN:-}"
|
||||
APP_WWW_DOMAIN="${APP_WWW_DOMAIN:-}"
|
||||
|
||||
PRIMARY_DOMAIN="${APP_DOMAIN:-localhost}"
|
||||
SERVER_NAMES="_"
|
||||
|
||||
if [ -n "$APP_DOMAIN" ]; then
|
||||
SERVER_NAMES="$APP_DOMAIN"
|
||||
if [ -n "$APP_WWW_DOMAIN" ]; then
|
||||
SERVER_NAMES="$SERVER_NAMES $APP_WWW_DOMAIN"
|
||||
fi
|
||||
fi
|
||||
|
||||
sed \
|
||||
-e "s|__SERVER_NAMES__|$SERVER_NAMES|g" \
|
||||
-e "s|__PRIMARY_DOMAIN__|$PRIMARY_DOMAIN|g" \
|
||||
/etc/nginx/default.conf.template > /etc/nginx/conf.d/default.conf
|
||||
16
webapp/frontend/docker-entrypoint.d/05-init-self-signed.sh
Normal file
16
webapp/frontend/docker-entrypoint.d/05-init-self-signed.sh
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
PRIMARY_DOMAIN="${APP_DOMAIN:-localhost}"
|
||||
CERT_DIR="/etc/letsencrypt/live/$PRIMARY_DOMAIN"
|
||||
FULLCHAIN="$CERT_DIR/fullchain.pem"
|
||||
PRIVKEY="$CERT_DIR/privkey.pem"
|
||||
|
||||
if [ ! -s "$FULLCHAIN" ] || [ ! -s "$PRIVKEY" ]; then
|
||||
echo "No TLS certificate for $PRIMARY_DOMAIN. Generating temporary self-signed cert."
|
||||
mkdir -p "$CERT_DIR"
|
||||
openssl req -x509 -nodes -newkey rsa:2048 -days 1 \
|
||||
-keyout "$PRIVKEY" \
|
||||
-out "$FULLCHAIN" \
|
||||
-subj "/CN=$PRIMARY_DOMAIN" >/dev/null 2>&1
|
||||
fi
|
||||
47
webapp/frontend/docker-entrypoint.d/10-certbot-loop.sh
Normal file
47
webapp/frontend/docker-entrypoint.d/10-certbot-loop.sh
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
APP_DOMAIN="${APP_DOMAIN:-}"
|
||||
APP_WWW_DOMAIN="${APP_WWW_DOMAIN:-}"
|
||||
ACME_EMAIL="${ACME_EMAIL:-}"
|
||||
CERTBOT_STAGING="${CERTBOT_STAGING:-0}"
|
||||
CERTBOT_RENEW_INTERVAL="${CERTBOT_RENEW_INTERVAL:-12h}"
|
||||
|
||||
if [ -z "$APP_DOMAIN" ] || [ -z "$ACME_EMAIL" ]; then
|
||||
echo "Skipping certbot: APP_DOMAIN or ACME_EMAIL is empty."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
(
|
||||
sleep 20
|
||||
|
||||
if [ ! -f "/etc/letsencrypt/renewal/$APP_DOMAIN.conf" ]; then
|
||||
rm -rf "/etc/letsencrypt/live/$APP_DOMAIN" "/etc/letsencrypt/archive/$APP_DOMAIN"
|
||||
fi
|
||||
|
||||
while :; do
|
||||
DOMAINS="-d $APP_DOMAIN"
|
||||
if [ -n "$APP_WWW_DOMAIN" ]; then
|
||||
DOMAINS="$DOMAINS -d $APP_WWW_DOMAIN"
|
||||
fi
|
||||
|
||||
STAGING_FLAG=""
|
||||
if [ "$CERTBOT_STAGING" = "1" ]; then
|
||||
STAGING_FLAG="--staging"
|
||||
fi
|
||||
|
||||
# shellcheck disable=SC2086
|
||||
certbot certonly \
|
||||
--webroot \
|
||||
-w /var/www/certbot \
|
||||
--agree-tos \
|
||||
--non-interactive \
|
||||
--email "$ACME_EMAIL" \
|
||||
--keep-until-expiring \
|
||||
$STAGING_FLAG \
|
||||
$DOMAINS || true
|
||||
|
||||
nginx -s reload || true
|
||||
sleep "$CERTBOT_RENEW_INTERVAL"
|
||||
done
|
||||
) &
|
||||
|
|
@ -1,25 +1,52 @@
|
|||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
listen [::]:80;
|
||||
server_name __SERVER_NAMES__;
|
||||
|
||||
location /.well-known/acme-challenge/ {
|
||||
root /var/www/certbot;
|
||||
}
|
||||
|
||||
location / {
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
listen [::]:443 ssl http2;
|
||||
server_name __SERVER_NAMES__;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/__PRIMARY_DOMAIN__/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/__PRIMARY_DOMAIN__/privkey.pem;
|
||||
ssl_session_timeout 1d;
|
||||
ssl_session_cache shared:SSL:10m;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_prefer_server_ciphers off;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# SPA fallback
|
||||
location /.well-known/acme-challenge/ {
|
||||
root /var/www/certbot;
|
||||
}
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://webapp-api:8080;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Connection "";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Telegram-Init-Data $http_x_telegram_init_data;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Proxy API requests to backend
|
||||
location /api/ {
|
||||
proxy_pass http://webapp-api:8080;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Telegram-Init-Data $http_x_telegram_init_data;
|
||||
}
|
||||
|
||||
# Cache static assets
|
||||
location ~* \.(css|js|png|jpg|svg|woff2?)$ {
|
||||
location ~* \.(css|js|png|jpg|jpeg|svg|woff2?)$ {
|
||||
expires 7d;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue