Виртуальное окружение (venv) — изолированная среда Python, которая позволяет устанавливать зависимости плагина без конфликта с системными пакетами.
Действие (Action) — функция платформы, которая вызывается при взаимодействии пользователя с интерфейсом или при внутреннем событии.
Плагин — механизм расширения функциональности платформы без изменения её исходного кода. Плагин регистрирует обработчики событий и функций через XML-файл.
Событие (Event) — механизм перехвата действий платформы. Обработчик события вызывается до или после выполнения основного действия.
Перед изучением статьи рекомендуется ознакомиться с документацией разработчиков COREmanager, поскольку часть механизмов наследуется из базового фреймворка.
Плагины позволяют изменять поведение платформы BILLmanager, перехватывать события, расширять интерфейс и добавлять новые функции. В статье описаны принципы работы плагинов, их структура и приведён пример создания плагина-валидатора. Об особенностях написания плагинов см. статью Создание плагина для BILLmanager.
Модификации BILLmanager
BILLmanager позволяет добавить следующие модификации:
модули — крупные и самостоятельные компоненты системы, которые выполняют определённую функцию или набор функций. Например, расширяют функциональность за счёт интеграции со сторонней системой. Модули:
обычно имеют свои зависимости и могут включать в себя несколько плагинов;
не предназначены для динамического изменения поведения системы в реальном времени;
могут требовать изменений в основной архитектуре системы;
по функциональности разделяются на:
обработчики услуг;
методы оплаты;
шлюзы сообщений;
онлайн-кассы;
модули документооборота;
модули для координации со сторонними системами — LDAP, Omni, amoCRM и т.д.
плагины — обычно более простые программные компоненты, которые добавляют определённую функциональность или изменяют поведение основной системы. Плагины позволяют:
интегрировать новые функции без изменения исходного кода;
перехватывать внутренние события;
добавлять новые элементы интерфейса;
выполнять фоновые задачи через триггеры на определённые события;
Плагины, изменяющие поведение, позволяют перехватывать внутренние события и модифицировать их. Например, добавить дополнительные поля в карточку клиента и заполнить их нужными значениями. Либо добавить новую логику для существующих действий. Подробнее см. статью документации COREmanager Изменение поведения системы через обработчики событий.
Плагины, расширяющие функциональность, добавляют:
новые элементы — настраиваемые таблицы и формы, необходимые для решения конкретных задач;
новые действия и сценарии работы с платформой.
Плагины, которые выполняют фоновые задачи:
добавляют триггеры на определённые события в платформе;
позволяют запускать в фоне “тяжёлые” задачи, которые не должны влиять на работу с платформой в интерфейсе. Например, выполнение фоновых задач на основе событий.
Имя файла-обработчика в директории /usr/local/mgr5/addon/. Имя должно совпадать с указанным в узле handler.
type
Способ взаимодействия BILLmanager со скриптом: cgi или xml:
type="cgi" — BILLmanager формирует переменные окружения с информацией о запросе и вызывает скрипт. Скрипт возвращает XML-файл в stdout. BILLmanager объединяет полученный XML-файл с основным;
type="xml" — BILLmanager формирует переменные окружения и передаёт основной XML-файл в stdin скрипта. Скрипт модифицирует XML-файл и возвращает его в stdout. BILLmanager подменяет основной XML-файл на полученный от скрипта.
protocol
Протокол вызова скрипта:
fcgi — вызов через FastCGI;
не указан — обычный вызов.
ignore_errors
Если указано значение yes, ошибки обработчика не влияют на работу платформы.
В платформе BILLmanager любое взаимодействие (например, пользовательское нажатие, внутренний вызов платформы или обращение от внешнего сервиса) вызывает функцию Action (действие). К Action привязываются Event (события), которые вызываются до, после или в обоих случаях (и до, и после) с действием в зависимости от настроек.
При регистрации плагина внутри handler используется один из следующих узлов:
func — регистрирует Action: скрипт становится обработчиком функции;
event — регистрирует Event: скрипт вызывается при наступлении события;
task — регистрирует Event: скрипт вызывается независимо от вызвавшего действия.
Чтобы получить список Action, используйте команду:
/usr/local/mgr5/sbin/mgrctl -m billmgr actionlist
Чтобы получить список Event для определённого Action, используйте команду:
Имя действия (Action), которое обрабатывает скрипт.
postdata
Укажите yes, чтобы передать тело POST-запроса в stdin скрипта. Актуально только для POST-запросов. Например, при сохранении формы sok=ok. Для GET-запросов тело всегда пустое.
Типичные задачи:
добавить страницу настроек плагина в административный интерфейс;
вывести список объектов, которыми управляет плагин;
добавить кнопку действия к существующему списку услуг;
валидировать поле формы при заказе (check.<name>).
event — Event, перехватчик действия
Плагин подписывается на Action BILLmanager и вызывается автоматически — до или после его выполнения.
Имя действия (Action), на который подписывается обработчик.
before
Укажите yes, чтобы вызвать скрипт до выполнения основного действия.
after
Укажите yes, чтобы вызвать скрипт после выполнения основного действия.
proirity
Определяет порядок вызова среди других обработчиков одного события: before — до базового обработчика, after — после. Если base не указан: before — в начало очереди, after — в конец.
Обратите внимание: атрибут пишется как proirity, а не priority — особенность COREmanager, сохранённая для совместимости.
base
Имя базового обработчика, относительно которого определяется порядок через proirity. project — подписаться на Action BILLmanager, а не COREmanager.
postdata
Укажите yes, чтобы передать тело POST-запроса в stdin скрипта. Актуально только для POST-запросов. Например, при сохранении формы sok=ok. Для GET-запросов тело всегда пустое.
Чтобы заблокировать стандартное действие, скрипт с before="yes" должен вернуть <skipaction/> в ответе:
<doc>
<skipaction/>
</doc>
Если <skipaction/> не возвращён, платформа выполнит стандартное действие в штатном режиме. Обработчики с after="yes" не вызываются, если стандартное действие было заблокировано через <skipaction/>.
Типичные задачи:
выполнить действие во внешней системе при открытии, закрытии или изменении услуги;
синхронизировать данные между BILLmanager и внешним сервисом;
заблокировать стандартное действие платформы и заменить его своим.
task — Event, независимый от породившего действия
Плагин подписывается на Action, но вызывается независимо от него. Основное выполнение не блокируется.
Имя действия (Action), на который подписывается обработчик.
get
Укажите yes, чтобы отслеживать Action без параметров sv_field и sok=ok в запросе.
submit
Укажите yes, чтобы отслеживать Action с параметром sok=ok в запросе.
setvalues
Укажите yes, чтобы отслеживать Action с параметром sv_field в запросе.
sv_field
Список полей через запятую для фильтрации по sv_field. Используется совместно с setvalues.
sessiondata
Укажите yes, чтобы передать в скрипт результат обработки Action в формате XML.
Типичные задачи:
периодически проверять статус объектов во внешней системе;
отправлять накопленные данные пакетом;
выполнять очистку или синхронизацию по расписанию.
Структура плагина
Плагин состоит из: XML-файла с описанием и скрипта (или нескольких скриптов) с логикой обработки.
/usr/local/mgr5/
├── etc/xml/
│ └── billmgr_mod_myplugin.xml # Регистрация и описание интерфейса.
└── addon/
└── myplugin.py # Логика обработки.
XML-файл
XML-файл регистрирует плагин в платформе и описывает элементы интерфейса. Размещается в директории /usr/local/mgr5/etc/xml/. Шаблон имени файла — billmgr_mod_<название>.xml.
Файл содержит корневой узел <mgrdata> с дочерними узлами:
Регистрирует плагин в платформе. Атрибут name — уникальное имя плагина.
handler
Регистрирует обработчики событий и функций. Подробнее см. раздел Задачи плагина.
metadata
Описывает элемент интерфейса: форму (type="form"), список (type="list") или отчёт (type="report"). Атрибут name — имя функции, которую описывает элемент.
mainmenu
Добавляет пункты в главное меню платформы. Атрибут level — уровень доступа.
lang
Описывает локализацию. Атрибут name — код языка (ru, en).
metadata
Узел metadata описывает формы, списки и отчёты. Атрибуты:
Все авторизованные пользователи. Доступ нельзя ограничить через политики прав — функция доступна, даже если в политиках используется значение "Всё запрещено".
registered
от 2
Все авторизованные пользователи.
nobody
= 0
Только неавторизованные пользователи.
user
= 16
Только обычные пользователи.
reseller
= 24
Только реселлеры.
admin
= 29
Только администраторы платформы.
super
= 30
Только администраторы сервера.
Модификаторы:
+ после имени — указанный уровень и выше. admin+ открывает доступ администраторам платформы (29) и администраторам сервера (30);
- после имени — указанный уровень и ниже. reseller- открывает доступ реселлерам (24) и всем с более низким уровнем;
несколько значений через запятую объединяются: admin,super+ эквивалентно admin+.
Для административных форм плагина рекомендуется level="admin+".
Значения all, registered и public уже означают "этот уровень и выше". Модификатор + для них избыточен. Для остальных значений без модификатора доступ ограничен указанным уровнем.
Пример плагина-валидатора
В разделе приведён пример создания плагина, который проверяет корректность доменного имени, введённого пользователем при заказе услуги.
Постановка задачи
При заказе услуги пользователь вводит доменное имя в поле плагина. Нужно проверять корректность введённого значения до оформления заказа. Стандартный валидатор domain.check не подходит, так как работает только со стандартными типами продуктов.
Решение
BILLmanager вызывает плагин-валидатор при проверке поля с атрибутом check="checker" и ожидает в ответ либо нормализованное значение, либо ошибку.
Структура проекта
checker/
├── addon/
│ └── checker.py # Логика валидации.
├── xml/
│ └── billmgr_mod_checker.xml # Регистрация плагина.
├── requirements.txt # Зависимости Python.
├── Makefile # Сборка и установка файлов.
└── install.sh # Установка зависимостей и плагина.
XML-файл
Файл регистрирует плагин и объявляет локализацию ошибок валидации. Размещается в /usr/local/mgr5/etc/xml/.
Ключи ошибок для валидатора имеют формат desc_ — без стандартного префикса msg_error_ (msg_error_ используется для всплывающих ошибок).
Регистрация валидаторов выполняется автоматически на основе паттерна именования check.*. Поэтому нужно зарегистрировать новый Action (func) с именем, содержащим check. в начале. Например, check.checker.
Пример XML-файла
<mgrdata>
<plugin name="checker">
<group>plugin</group>
<author>BILLmanager team</author>
</plugin>
<handler name="checker.py" type="cgi">
<func name="check.checker"/>
</handler>
<lang name="ru">
<messages name="msgerror">
<msg name="desc_too_short">Значение слишком короткое (минимум 3 символа).</msg>
<msg name="desc_has_spaces">Значение не должно содержать пробелы.</msg>
<msg name="desc_empty_domain">Доменное имя не может быть пустым.</msg>
<msg name="desc_domain_too_short">Доменное имя слишком короткое (минимум 2 символа).</msg>
<msg name="desc_domain_too_long">Доменное имя слишком длинное (максимум 63 символа на метку).</msg>
<msg name="desc_invalid_hyphen_position">Дефис не может быть в начале или конце метки домена.</msg>
<msg name="desc_invalid_dot_position">Точка не может быть в начале или конце доменного имени.</msg>
<msg name="desc_invalid_domain_format">Неверный формат доменного имени. Например: example.com</msg>
<msg name="desc_reserved_domain">Это доменное имя зарезервировано и не может быть использовано.</msg>
<msg name="desc_too_many_labels">Слишком много уровней в доменном имени (максимум 127).</msg>
<msg name="desc_invalid_chars">Доменное имя содержит недопустимые символы. Разрешены: буквы, цифры, дефис и точка.</msg>
<msg name="desc_tld_too_short">TLD (последняя часть домена) должна содержать минимум 2 буквы.</msg>
<msg name="desc_invalid_tld">TLD может содержать только буквы.</msg>
</messages>
</lang>
<lang name="en">
<messages name="msgerror">
<msg name="desc_too_short">Value is too short (minimum 3 characters).</msg>
<msg name="desc_has_spaces">Value must not contain spaces.</msg>
<msg name="desc_empty_domain">Domain name cannot be empty.</msg>
<msg name="desc_domain_too_short">Domain name is too short (minimum 2 characters).</msg>
<msg name="desc_domain_too_long">Domain name is too long (maximum 63 characters per label).</msg>
<msg name="desc_invalid_hyphen_position">Hyphen cannot be at the beginning or end of a domain label.</msg>
<msg name="desc_invalid_dot_position">Dot cannot be at the beginning or end of domain name.</msg>
<msg name="desc_invalid_domain_format">Invalid domain name format. Example: example.com</msg>
<msg name="desc_reserved_domain">This domain name is reserved and cannot be used.</msg>
<msg name="desc_too_many_labels">Too many levels in domain name (maximum 127).</msg>
<msg name="desc_invalid_chars">Domain name contains invalid characters. Allowed: letters, numbers, hyphen and dot.</msg>
<msg name="desc_tld_too_short">TLD must contain at least 2 letters.</msg>
<msg name="desc_invalid_tld">TLD can contain only letters.</msg>
</messages>
</lang>
</mgrdata>
Скрипт
При разработке на Python рекомендуем выполнять весь код в виртуальном окружении. Примеры показаны в коде ниже и в разделе Makefile — это разные варианты обеспечить изоляцию в виртуальном окружении.
Представленный ниже код — пример решения задачи, а не готовое решение.
Набор разработчика SDK BILLmanager не содержит готовых абстракций для плагинов, но на основе информации из раздела Структура плагина можно написать следующий код:
Пример кода
Пример кода
#!/usr/bin/env python3
import sys
import os
import xml.etree.ElementTree as ET
from abc import abstractmethod
from typing import Callable, Dict
# Проверяем что плагин запущен внутри venv.
# Если нет — перезапускаем плагин через venv-интерпретатор.
VENV_PYTHON = "/usr/local/mgr5/src/checker/venv-checker/bin/python3"
if sys.executable != VENV_PYTHON and os.path.exists(VENV_PYTHON):
os.execv(VENV_PYTHON, [VENV_PYTHON] + sys.argv)
sys.path.insert(0, "/usr/local/mgr5/lib/python")
import billmgr.logger as logging
import billmgr.session as session
import billmgr.exception as bill_exc
_logger = logging.get_logger("checker")
# ---------------------------------------------------------------------------
# ValueException
# ---------------------------------------------------------------------------
class ValueException(bill_exc.XmlException):
"""Ошибка валидации поля формы. Используется внутри CheckerPlugin.check().
Эквивалент C++ mgr_err::Value(object, value, check, args).
BILLmanager отобразит локализованное сообщение с ключом desc_{check}.
Локализация объявляется в XML-файле плагина.
Args:
check: имя валидатора.
args: параметры валидатора.
Пример:
raise ValueException(check="too_short")
raise ValueException(check="invalid_format", args=r"\\d+")
"""
def __init__(self, err_object="", err_value="", check: str = None, args: str = None):
super().__init__("value", err_object, err_value)
self.messages = {}
self.params = {}
self.check = check
if self.err_object:
self.add_message("object", self.err_object)
if self.err_value:
self.add_param("value", self.err_value)
if args:
self.add_param("args", args)
if check:
self.add_message("desc", "desc_" + check)
else:
self.add_message("desc", "desk_empty")
def add_message(self, name: str, value: str):
self.messages[name] = value
def as_xml(self):
doc = ET.Element("doc")
error = ET.SubElement(doc, "error")
error.set("type", self.err_type)
error.set("object", self.err_object)
for param in self.params:
param_node = ET.SubElement(error, "param")
param_node.text = self.params[param]
param_node.set("name", param)
for msg in self.messages:
msg_node = ET.SubElement(error, "param")
msg_node.text = self.messages[msg]
msg_node.set("name", msg)
msg_node.set("type", "msg")
return ET.tostring(doc, encoding="unicode")
# ---------------------------------------------------------------------------
# Plugin
# ---------------------------------------------------------------------------
class Plugin:
"""Универсальный базовый класс для плагинов BILLmanager.
Один handler в BILLmanager передаёт и события (event), и функции (func) и таски(task)
одинаково — через query-параметр "func". Поэтому Plugin обрабатывает
все случаи через единый механизм handle().
Наследник регистрирует обработчики в __init__ через handle():
Сигнатура обработчика: (func: str, xml: ET.Element) -> str | None
func: имя текущей функции/события (из get_query_param("func"))
xml: входящий XML-код запроса
return: XML-строка для ответа, или None — тогда возвращается <doc/>
"""
#: Имя плагина — используется для инициализации логгера.
name: str = ""
def __init__(self) -> None:
if not self.name:
raise ValueError(
f"{self.__class__.__name__} must define class attribute 'name'"
)
logging.init_logging(self.name)
self._logger = logging.get_logger(self.name)
self._handlers: Dict[str, Callable] = {}
def handle(self, func: str, handler: Callable) -> None:
"""Зарегистрировать обработчик.
Args:
func: имя функции BILLmanager.
handler: callable с сигнатурой (func: str, xml: ET.Element) -> str | None.
"""
self._handlers[func] = handler
def _dispatch(self, xml: ET.Element) -> str:
func = session.get_query_param("func", "")
self._logger.info("func=%r", func)
handler = self._handlers.get(func)
if handler is None:
self._logger.debug("no handler for %r, skipping", func)
return '\n<doc/>'
result = handler(func, xml)
return result if result is not None else '\n<doc/>'
def run(self) -> None:
"""Точка входа. Вызывается в if __name__ == '__main__'."""
try:
xml = session.get_input_xml(True)
session.debug_session(xml)
_logger.debug(f"environ: {session.envs()}")
_logger.debug(f"input xml: {ET.tostring(xml, encoding='unicode')}")
res = self._dispatch(xml)
self._logger.debug(f"ans={res}")
print(res)
except bill_exc.XmlException as err:
bill_exc.log_backtrace()
print(err.as_xml())
except Exception as err:
bill_exc.log_backtrace()
print(bill_exc.XmlException("unknown", "what", str(err)).as_xml())
# ---------------------------------------------------------------------------
# CheckerPlugin
# ---------------------------------------------------------------------------
class CheckerPlugin(Plugin):
"""Базовый класс для плагинов-чекеров. Наследник Plugin.
Вызывается BILLmanager при валидации поля формы с атрибутом check="<name>".
Автоматически регистрирует обработчик check.{name} и оборачивает вызов check().
XML-регистрация:
<handler name="checker.py" type="xml">
<func name="check.checker"/>
</handler>
Наследник обязан задать name и реализовать check():
class MyChecker(CheckerPlugin):
name = "checker"
def check(self, value: str, args: str) -> str:
if len(value) < 3:
raise ValueException(check="too_short")
return value.strip()
if __name__ == "__main__":
MyChecker().run()
"""
def __init__(self) -> None:
super().__init__()
# Регистрируем обработчик check.{name} через родительский handle()
self.handle(f"check.{self.name}", self._check_handler)
@abstractmethod
def check(self, object:str, value: str, args: str) -> str:
"""Логика валидации. Переопределить в наследнике.
Args:
value: введённое пользователем значение (уже нормализовано методом strip()).
args: опциональный аргумент из атрибута поля формы.
Returns:
Нормализованное значение подставляется обратно в поле.
Raises:
ValueException, если значение не прошло проверку.
"""
raise NotImplementedError
def _check_handler(self, func: str, xml: ET.Element) -> str:
"""Внутренний обработчик читает параметры и вызывает check()."""
raw_value = session.get_query_param("value", "")
field_name = session.get_query_param("name", "value")
args = session.get_query_param("args", "")
self._logger.info(
"check.%s called, field=%r value=%r", self.name, field_name, raw_value
)
value = raw_value.strip()
if not value:
doc = ET.Element("doc")
ET.SubElement(doc, "value")
return ET.tostring(doc, encoding="unicode")
try:
normalized = self.check(field_name, value, args)
except ValueException as err:
self._logger.info(
"check.%s failed: desc_%s field=%r value=%r",
self.name, err.check, field_name, value,
)
return err.as_xml()
doc = ET.Element("doc")
ET.SubElement(doc, "value").text = normalized
return ET.tostring(doc, encoding="unicode")
import re
class MyChecker(CheckerPlugin):
name = "checker"
def check(self, object: str, value: str, args: str) -> str:
"""
Проверка доменного имени.
Args:
object: имя поля. Например, "domain".
value: проверяемое значение
args: параметры проверки. Вы можете передать требования. Например "min_length=2,max_length=63".
"""
# Извлекаем аргументы, если они переданы.
min_length = 2
max_length = 63
allow_unicode = False
if args:
for arg in args.split(','):
if '=' in arg:
key, val = arg.split('=')
if key == 'min_length':
min_length = int(val)
elif key == 'max_length':
max_length = int(val)
elif key == 'allow_unicode':
allow_unicode = val.lower() == 'true'
# Проверяем, что значение не пустое.
if not value:
raise ValueException(check="empty_domain", err_object=object)
# Проверяем, чтобы длина доменного имени соответствовала требованиям.
if len(value) < min_length:
raise ValueException(check="domain_too_short", err_object=object)
if len(value) > max_length:
raise ValueException(check="domain_too_long", err_object=object)
# Проверяем на наличие пробелов.
if ' ' in value:
raise ValueException(check="has_spaces", err_object=object)
# Проверяем на наличие недопустимых символов в начале или в конце.
if value.startswith('-') or value.endswith('-'):
raise ValueException(check="invalid_hyphen_position", err_object=object)
if value.startswith('.') or value.endswith('.'):
raise ValueException(check="invalid_dot_position", err_object=object)
# Проверяем формат домена.
if not self._validate_domain_format(value, allow_unicode):
raise ValueException(check="invalid_domain_format", err_object=object)
# Выполняем дополнительную проверку на зарезервированные имена:
reserved = [
'localhost', 'local', 'example', 'invalid', 'test',
'xn--', # punycode префикс недопустим
]
domain_lower = value.lower()
for reserved_name in reserved:
if domain_lower == reserved_name:
raise ValueException(check="reserved_domain", err_object=object)
# Проверяем количество точек. Максимум 127 для полного домена.
if value.count('.') > 127:
raise ValueException(check="too_many_labels", err_object=object)
return value.strip()
def _validate_domain_format(self, domain: str, allow_unicode: bool = False) -> bool:
"""
Проверяем формат доменного имени.
"""
if allow_unicode:
# Для Unicode доменов (IDNA)
# Проверяем, что доменное имя можно сконвертировать в ASCII
try:
domain.encode('idna').decode('ascii')
except (UnicodeError, UnicodeEncodeError):
return False
# Строгая валидация ASCII домена.
# Метки: буквы, цифры, дефис (не в начале и не в конце).
# Длина метки: 1-63 символа.
# TLD: только латинские буквы, минимум 2 символа.
pattern = r'^(?!(?:[0-9]+\.)+[0-9]+$)(?!-)[A-Za-z0-9-]{1,63}(?<!-)\.(?:[A-Za-z]{2,}|[A-Za-z0-9-]{1,63}(?<!-))$'
# Проверяем полное доменное имя с несколькими поддоменами.
full_pattern = r'^(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$'
if not allow_unicode:
# Проверяем на допустимые символы для ASCII доменов.
allowed_chars = set('abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789.-')
if not all(c in allowed_chars for c in domain):
return False
# Проверяем через регулярное выражение.
if not re.match(full_pattern, domain):
# Проверяем каждую метку отдельно:
labels = domain.split('.')
for i, label in enumerate(labels):
# Отклоняем пустые метки.
if not label:
return False
# Проверяем длину метки.
if len(label) > 63 or len(label) < 1:
return False
# Проверяем, что TLD (последняя метка) содержит только буквы и длина метки не менее 2 символов.
if i == len(labels) - 1:
if not re.match(r'^[A-Za-z]{2,}$', label):
return False
else:
# Проверяем, что имя поддоменов начинается и заканчивается буквой или цифрой.
if not re.match(r'^[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?$', label):
return False
return True
if __name__ == "__main__":
MyChecker().run()
Makefile
Makefile отвечает за копирование файлов плагина в нужные директории BILLmanager. Его вызывает install.sh после установки зависимостей.
isp.mk
— стандартный набор правил сборки ISPsystem. Он определяет переменные DISTDIR, цель install и другие вспомогательные цели. Подключается в конце через include.
XML-файл и изображения загружаются автоматически, если соблюдена нужная структура:
XML-файл в папку xml на уровне Makefile;
изображения — в dist/skins/common/img/*.
После этого изображения можно использовать, например, в таких формах при имени изображения sad.png: