mindsellers
72 subscribers
25 photos
8 files
32 links
Практики из жизни Linux-админа. Asterisk, Bash, Python и черная магия.

Сайт http://mindsellers.ru
Группа VK https://vk.com/mindsellers
ТыТруба https://www.youtube.com/channel/UC7LUJgzIUL4VGiOWctoa0fw
По всяким вопросам обращаться @alex_dmit
Download Telegram
Синтез и распознавание речи с помощью Yandex Speech API, python и asterisk
Сегодня рассмотрим довольно типовую задачу: информирование клиента в автоматическом режиме с синтезом речи, а также распознавание его ответа. Существует некоторое количество бесплатных инструментов как для синтеза, так и для распознавания речи, но к сожалению, ни один из них не может похвастаться высоким качеством работы. Именно поэтому рассмотрим использование платного сервиса от компании Yandex.

В первую очередь потребуется создать аккаунт на Облаке. Привязав любую пластиковую карту(с карты будет списана и тут же возвращена незначительная сумма) мы получим 4000 тестовых рублей аж на два месяца на использование любых облачных сервисов компании. Нас же интересуют исключительно речевые технологии. На момент написания статьи расценки таковы: синтез 1 млн символов - 183 рубля, распознавание фрагмента до 15 секунд - 15.2 копейки, что вполне доступно.

Яндекс предлагает довольно гибкое распределение ролей доступа к системе, но если мы используем только речевые технологии, то нам будет достаточно создать сервисный аккаунт и получить для него API-ключ, который мы будем в дальнейшем использовать в скриптах. Инструкция по данному вопросу доступна по ссылке.

Итак, рассмотрим типовой диалог:

Здравствуйте, Иван Иванович. Ваш заказ номер 234 456 доступен для получения в пункте выдачи по адресу Ленина, 1. Если вы хотите поговорить
с оператором, произнесите слово ОПЕРАТОР
Соединяем с оператором/всего доброго
Начнем со скрипта, который будет генерировать любой текст. На сайте Яндекса есть пример реализации на python, однако в примере рассматривается iam-аутентификация, а мы хотим работать по API-ключу, да и всякие свойства API описаны отдельно. Итак, листинг скрипта ниже, но для того, чтобы он работал, необходимо установить в системе sox, а также поставить requests и pysox через pip

pip install requests
pip install sox

#!/usr/bin/env python

import argparse
import requests
import sox
import os

def synthesize(output, text):
url = 'https://tts.api.cloud.yandex.net/speech/v1/tts:synthesize'
headers = {
'Authorization': 'Api-Key ' + 'AQVN33ioCUgKDF-XDXXXXXXXXX-oWqt7zIrX0ZW-', #авторизация
}

data = {
'text': text, #cинтезируемый текст
'lang': 'ru-RU', #язык
'voice': 'alyss', #голос
'emotion': 'good',
'format': 'lpcm', #формат
'sampleRateHertz': '8000'
}

resp=requests.post(url, headers=headers, data=data, stream=True, verify=False)
if resp.status_code != 200:
raise RuntimeError("Invalid response received: code: %d, message: %s" % (resp.status_code, resp.text))

for chunk in resp.iter_content(chunk_size=None):
with open(output, "wb") as f:
for content in chunk:
f.write(content)

tfm = sox.Transformer()
tfm.set_input_format(file_type='raw', rate=8000, bits=16, channels=1, encoding='signed-integer')
tfm.build(output, output+'.wav')
os.remove(output)


if name == "main":
parser = argparse.ArgumentParser()
parser.add_argument("--text", required=True, help="Text for synthesize")
parser.add_argument("--output", required=True, help="Output file name")
args = parser.parse_args()
synthesize(args.output, args.text)


Итак, мы принимаем 2 аргумента - текст и имя файла. Расширение мы указывать не будем, так как яндекс отдаст нам сырой файл, без заголовков, и далее, с помощью sox, нам все равно придется его превращать в кошерный wav. На выходе мы получим файл с расширением wav, который без проблем "сожрет" asterisk.

Теперь давайте рассмотрим скрипт, который будет заниматься распознаванием текста. В примере Яндекса он написан на python3, что нам, в общем, вполне подходит, так как результат его действий нам все равно нужно получить без перевода каретки(а то asterisk не сможет его обработать), а python2 по умолчанию так не умеет.

#!/usr/bin/env python3
import urllib.request
import json
import sys
import os

file=sys.argv[1]
FOLDER_ID = "default" # Идентификатор каталога
API = "AQVN33ioCUgKDF-XXXXXXXXX-oWqt7zIrX0ZW-"

with open(file, "rb") as f:
data = f.read()

params = "&".join([
"topic=general",
"format=lpcm",
"sampleRateHertz=8000",
"lang=ru-RU"
])

url = urllib.request.Request("https://stt.api.cloud.yandex.net/speech/v1/stt:recognize?%s" % params, data=data)
url.add_header("Authorization", "Api-Key %s" % API)

responseData = urllib.request.urlopen(url).read().decode('UTF-8')
decodedData = json.loads(responseData)

os.remove(file)

if decodedData.get("error_code") is None:
resp=decodedData.get("result")
if resp.startswith('да') or resp.startswith('хорошо') or resp.startswith('норм') or resp.startswith('ладно') or 'оператор' in resp:
print('yes',end='')
elif resp.startswith('нет'):
print('no',end='')
elif 'абонент' in resp:
print('no',end='')
elif 'авто' in resp:
print('no',end='')
elif resp=='':
print('no',end='')
else:
print('dont',end='')



Здесь, в принципе, все понятно без комментариев - мы по-прежнему указываем в данных, что именно мы передаем, подсовываем файл с записью и получаем результат. В зависимости от того, что было распознано, мы получаем от скрипта в stdout yes, no или dont, соответственно, Да, Нет и Не понял.

В рассматриваемом примере, к сожалению, никакой интеграции asterisk с crm-системой нет, так что пойдем по пути наименьшего сопротивления, и будем принимать данные через get-запрос. Сделать это можно по-разному: от написания собственного мультипоточного socket-сервера до использования django, но я решил использовать супер-легковесный web-фреймворк под названием bottle. Так что начинаем с

pip install bottle
Сам фреймворк целиком состоит из единственного файла, так что можно его просто скачать и положить в каталог со своим приложением. Итак, перейдем к коду:


#!/usr/bin/env python
# -*- coding: utf-8 -*-
from bottle import run, request, get
import os
from syn import synthesize

def autodial(mobile,order):
with open('/tmp/'+str(mobile)+str(order), 'w') as callfile:
callfile.write('Channel: Local/'+mobile+'@from-internal'+'\n')
callfile.write('Callerid: Autodial <'+mobile+'>\n')
callfile.write('Context: from-internal\n')
callfile.write('Extension: 900\n')
callfile.write('Priority: 1\n')
callfile.write('WaitTime: 25000\n')
callfile.write('Set: __ORDER='+str(order)+'\n')
os.rename('/tmp/'+str(mobile)+str(order), '/var/spool/asterisk/outgoing/'+str(mobile)+str(order))

@get('/msg')
def index():
postdata = request.body.read()
name = request.query.get("name")
surname = request.query.get("surname")
number = request.query.get("number")
order = request.query.get("order")
address = request.query.get("address")
synthesize(output='/tmp/'+str(order)+'-name',text=str(name)+' '+str(surname))
synthesize(output='/tmp/'+str(order)+'-order',text=str(order))
synthesize(output='/tmp/'+str(order)+'-address',text=str(address))
autodial(mobile=number,order=order)


run(host='192.168.0.22', port=8000, debug=False)


Итак, после запуска сервера на указанном адресе и порту, мы отслеживаем исключительно get-запросы в /msg. Функция autodial в данном случае формирует callfile для asterisk, причем только после того, как мы сформировали необходимые файлы. Функция synthesize импортируется из первого скрипта. Запустим скрипт и в браузере откроем ссылку

http://192.168.0.22:8000/msg?name=Иван&surname=Петров&order=23232&address=Ленина 1&number=89278555666
Результатом работы данного кода будет:

три файла с названиями вида /tmp/23232-name.wav /tmp/23232-order.wav /tmp/23232-address.wav
callfile, который осуществит вызов клиента, соединит его со служебным номером 900, а также передаст в контекст переменную ORDER, соответствующую номеру заказа.
Теперь нам нужно сгенерировать те части фразы, которые будут неизменны. Сделаем это с помощью нашего скрипта, например,
./syn.py --output='/opt/autodial/syn/сonnect' --text='Ваш звонок переводится на оператора '
Повторим эту процедуру для всех необходимых фраз, и получим файлы

address.wav
goodbye.wav
hello.wav
operator.wav
order.wav
сonnect.wav
размещенные в указанном каталоге.

Самое время заняться asterisk. По старой традиции я покажу пример с FreePBX, так как на чистом asterisk все во-первых, чуть проще, а во-вторых, работают более опытные люди, которым мои контексты, в общем-то, и не нужны.

В секцию [from-internal-custom] файла extensions_custom.conf внесем следующее:

exten => 900,1,Answer()
exten => 900,n,Noop(${ORDER})
exten => 900,n,Playback(/opt/autodial/syn/hello) ;здравствуйте
exten => 900,n,Playback(/tmp/${ORDER}-name) ;Иван Петров
exten => 900,n,Playback(/opt/autodial/syn/order) ;ваш заказ с номером
exten => 900,n,Playback(/tmp/${ORDER}-order) ;23232
exten => 900,n,Playback(/opt/autodial/syn/address) ;готов к выдаче по адресу
exten => 900,n,Playback(/tmp/${ORDER}-address) ;Ленина 1
exten => 900,n,Playback(/opt/autodial/syn/operator) ;Если вы хотите соединиться
;с оператором, скажите ОПЕРАТОР
exten => 900,n(record),Monitor(wav,/tmp/${UNIQUEID},o) ;включаем запись
exten => 900,n,Wait(2) ;ждем 2 секунды
exten => 900,n,StopMonitor() ;останавливаем
exten => 900,n,Noop(/tmp/${UNIQUEID}-in.wav)
exten => 900,n,Set(RESULT=${SHELL(/usr/bin/python3 /opt/autodial/yandex.py /tmp/${UNIQUEID}-in.wav)})
;передаем запись скрипту и ждем результата
exten => 900,n,Noop(${RESULT})
exten => 900,n,GotoIf($["${RESULT}" = "yes"]?yes) ;если он yes, переходим на метку
exten => 900,n,Playback(/opt/autodial/syn/goodbye) ;иначе прощаемся
exten => 900,n,Hangup()
exten => 900,n(yes),Playback(/opt/autodial/syn/сonnect) ;Ваш звонок переводится на оператора
exten => 900,n,Goto(from-internal,600,1) ;Соединение с оператором или очередью
exten => 900,n,Hangup()


Как несложно заметить, в данном случае мы обрабатывали только один ответ скрипта, положительный. Однако сам скрипт отдает, как было сказано выше, 3 разных варианта(как обычно, скрипт остался от другой задачи)

На этом, собственно, все. Из возможных доработок видится логичным записать заранее все адреса пунктов выдачи, и не генерировать их, а в переменной address в get-запросе передавать просто порядковый номер пункта. Записи в таком случае имеет смысл назвать address-1, address-2 и так далее, при формировании callfile передать также переменную ADDRESS, ну и соответственно, проигрывать файл с именем address-${ADDRESS} из контекста.

Листинг скриптов удобнее посмотреть в wiki
http://pubwiki.mindsellers.ru/index.php/%D0%A1%D0%B8%D0%BD%D1%82%D0%B5%D0%B7_%D0%B8_%D1%80%D0%B0%D1%81%D0%BF%D0%BE%D0%B7%D0%BD%D0%B0%D0%B2%D0%B0%D0%BD%D0%B8%D0%B5_%D1%80%D0%B5%D1%87%D0%B8_%D1%81_%D0%BF%D0%BE%D0%BC%D0%BE%D1%89%D1%8C%D1%8E_Yandex_Speech_API

#python #asterisk #yandex #speech
Перенос ubuntu на систему с EFI

Буквально сегодня возникла задача - срочно перенести Ubuntu 14.04 на новое железо со старого(накрылась материнка). Система ставилась довольно давно, мать тоже была старая. Казалось бы - все тривиально: выдернул винты, воткнул в новое железо, и получай удовольствие. Но не тут-то было! Старая мать знать не знала про UEFI, а новая, соответственно, только про UEFI и знает, никакого legacy.

В общем, не буду растягивать прелюдию. Для восстановления нам потребуется загрузочная флешка с чем-то убунту-образным. У меня нашлась с Linux Mint 18 x64, и на ней все прекрасно получилось.

Загружаемся с флешки и запускаем Gparted. Необходимо "отрезать" от диска раздел(я отрезал 512Мб) в самом начале, отформатировать его в FAT32, поставить метку тома EFI и флаг boot. Затем установить и запустить boot repair:

sudo add-apt-repository -y ppa:yannubuntu/boot-repair && sudo apt -y install boot-repair && boot-repair


Ничего в интерфейсе менять не надо, останавливаем свой выбор на Recommended repair и запускаем процесс. В процессе приложение попросит выполнить несколько действий из консоли, суть которых сводится к удалению старого grub из системы, установке нового вместе с поддержкой UEFI, ну и естественно, использованием свежесозданного раздела в качестве загрузочного. По завершении работы мастера выключаем компьютер и грузимся в восстановленную, работающую систему.

#linux #bios #uefi
Уведомление в telegram из zabbix

Задача мониторинга всегда актуальна. Одним из лучших продуктов для ее реализации я считаю Zabbix. Но к сожалению, по умолчанию он умеет слать уведомления по почте и jabber. При этом существует возможность использования внешних скриптов для обработки алярмов.

Телеграм предоставляет аж 2 разных API - для ботов и для клиентов. Про реализацию бота я уже писал, так что сейчас рассмотрим реализацию клиента. Для начала, нужно пойти на сайт телеграма, получить id и токен доступа к API client. После этого можно перейти к скрипту.

Для реализации доступа к API client существует прекрасная библиотека под названием Telethon, так что начнем с ее установки:

pip3 install telethon


Обратите внимание: либа реализована только под python3.x!

Работа с либой предельно проста, например, для посылки сообщения кому-то из своих контактов, требуется написать что-то вроде

client.send_message(alex_dmit, 'Привет!')


Однако слать сообщения в личку каждому из заинтересованных - не наш метод, нам нужно слать уведомления в чат. К сожалению, примера для отправки сообщений в группу я не нашел, но поковырявшись в самой библиотеке обнаружил метод: нужно по ссылке для приглашения в чат получить его идентификатор, а уже после слать сообщение. Рассмотрим весь скрипт:
#!/usr/bin/python3
# -*- coding: utf-8 -*-

from telethon import TelegramClient
import sys
api_id = 242424
api_hash = '8d79f63dfsfsfsdfsfsdfs'#параметры подключения
phone_number = '+79063448810' # номер телефона


client = TelegramClient('/usr/lib/zabbix/alertscripts/%sessionname%', api_id, api_hash)
client.connect()
if not client.is_user_authorized():
client.send_code_request(phone_number)
client.sign_in(phone_number, input('Enter the code: '))
print(sys.argv[1])
destination_group_invite_link='https://t.me/joinchat/Dk3ZYRWFRXXXXXXXXpI7ADaQ'
entity=client.get_entity(destination_group_invite_link)
client.send_message(entity=entity, message=sys.argv[1:])



Как несложно понять, destination_group_invite_link нужно скормить ссылку-приглашение в группу. При первом запуске скрипт запросит код подтверждения, который будет выслан на любое другое устройство, с которого в этот момент авторизован пользователь. После этого по указанному адресу(в нашем случае /usr/lib/zabbix/alertscripts/) будет создан файл сессии, и пока мы его не удалим, скрипт будет работать без запросов подтверждения.

Отправлять в чат мы будем все, что получили в качестве аргументов: sys.argv[1:]

Дадим права на запуск, и проверим работоспособность скрипта:

chmod +x ./send.py
./send.py 'Тестовое сообщение'


Переходим к настройке заббикса. Все, описанное ниже, имеет отношение к версии 3.4, но насколько мне известно, и в 2.х и в 4.х все делается примерно так же.

Для начала, нужно зайти в Настройка->Действия и включить дефолтное действие Report problems to Zabbix administrators. Затем, в Администрирование->Способы оповещения добавим новый способ под названием telegram. Тип оповещения "скрипт", параметры оповещения {ALERT.SUBJECT} и {ALERT.MESSAGE}.

Остается только перейти в настройки своей учетной записи и включить способ уведомлений telegram.

Теперь можно смело обвалить один из серверов и убедиться в том, что уведомления поступают в нужный чатик.

#zabbix #linux #python #telegram #telethon
Отправка уведомлений в телеграм с помощью простейшего бота

Итак, рассмотрим простого бота, предназначенного для отправки уведомлений в каналы telegram.

Заметка ценности не представляет, и в первую очередь нужна мне, чтобы в следующий раз в попыхах не вспоминать, как заставить telebot работать с socks

В первую очередь, нужно бота создать, делается это с помощью служебного бота @BotFather, который даст нам token нашего бота. Затем, создадим канал, добавим туда нашего бота по имени в качестве администратора. Внимание! Канал нужно создать публичным. Используя любую машину, где есть доступ к телеграму, получим id нашего канала:

curl https://api.telegram.org/bot<токен>/getChat?chat_id=@<название канала>

В выхлопе будет json, содержащий id. После этого канал можно сделать приватным, id у него уже не изменится.

Поставим все необходимое
apt install python3-pip
pip3 install setuptools
pip3 install pytelegrambotapi
pip3 install requests[socks]

requests[socks] нам будет необходим для того, чтобы наш бот умел работать через socks-прокси. Код поражает своей элементарностью:

#!/usr/bin/python3
# -*- coding: utf-8 -*-
import sys
import telebot
from telebot import apihelper
try:
id,message=sys.argv[1:]
except:
print('Необходимо 2 аргумента - id и сообщение')
exit(1)


apihelper.proxy={'https':'socks5h://proxyuser@proxypass@domain.ltd:port'}
token = '10300000:AAGRVSSSSSSSSSSSSS'
bot=telebot.TeleBot(token)
bot.send_message(id, message)

На входе принимаем id чата, группы, канала - не принципиально, на выходе имеем сообщение в канал. Понятно, что любой человек, "знакомый" с ботом, может принимать сообщения себе в личку от этого бота, нужно только указать личный id, узнать который можно методом, описанным в начале, или через бота @userinfobot Естественно, это просто маленькая заготовка, и следует расширить функциональность бота до интерактива, о чем я уже писал ранее.


UDP для любителей однострочников на баше:



curl -x socks5h://proxyuser:proxypass@proxydomain.ltd:[proxyport] "https://api.telegram.org/bot103000000:XXXXXXXXXXXXX/sendMessage?chat_id=[id]&text=[sometext] "


#python #telegram #onestringscript #bash
Уведомления о новых задачах redmine в telegram


После установки редмайна и настройки почты, возникает резонный вопрос: как узнавать о новых заявках, которые еще не были никому назначены. В этих ваших интернетах целый вагон платных и бесплатных плагинов для интеграции redmine с telegram, но все они тяжеловесны, и как я недавно обнаружил с помощью моего коллеги, бездумная установка плагинов ведет к падению redmine чуть чаще, чем всегда.

Поэтому, не будем использовать плагины, но воспользуемся питоном. Для начала, создадим в redmine пользователя с админскими правами(да, можно и без них, но с ними веселее), допустим, bot, а также в Настройках включим API. После этого приступаем к питоно-коду. Чуть ранее я писал о том, как легко отсылать уведомления в телегу через бота, читайте выше. Не буду повторяться, но приведу листинг чуть модернизированного скрипта
#!/usr/bin/python3
# -*- coding: utf-8 -*-
import sys
import telebot
from telebot import apihelper

def messagesend(id, message):
apihelper.proxy={'https':'socks5h://proxy-user:proxy-pass@domain.ltd:[port]'}
token = '1037455712:XXXXXXXXXXXXXXX'
bot=telebot.TeleBot(token)
bot.send_message(id, message)


if __name__=='__main__':
try:
id,message=sys.argv[1:]
except:
print('Необходимо 2 аргумента - id и сообщение')
exit(1)
messagesend(id,message)
Всего-то я вынес отправку в функцию и написал классический
 __name__=='__main__'
: для тестирования.

Теперь поставим очень удобную и простую либу:
pip3 install python-redmine
и напишем элементарный скрипт. Отметим, что скрипт выше называется у меня
telegram_send.py
и лежит в одном каталоге с нижеследующим.

#!/usr/bin/python3

from redminelib import Redmine
from telegram_send import messagesend
redmine = Redmine('https://redmine.domain.ltd/', username='bot', password='BOTPASS')

CHATID='-100XXXXXXXXXXXX'

count=0
for issue in redmine.issue.filter(status_id=1): #status_id==1 Обычно соответствует новой задаче
#но вы можете воспользоваться отладочными строками ниже для определения своего id


# Строчки ниже могут пригодиться для отладки и проверки скрипта
# print(str(issue.id)+' '+str(issue))
# print(str(issue.status.id)+' '+str(issue.status))
# print(getattr(issue, 'assigned_to', None))
if getattr(issue, 'assigned_to', None)==None: #проверяем, что задача никому не назначена
count+=1 #считаем неназначенные
message=message+str(issue)+' в проекте '+str(issue.project)+': http://redmine.domain.ltd/issues/'+str(issue.id)+'\n'


if count>=1:
message='Новых неназначенных задач '+str(count)+'. Вы можете посмотреть их список ниже: \n'
print(message)
messagesend(CHATID, message)

Написанному скрипту даем права на исполнение и запихиваем в cron по вкусу.

В результате получаем следующее: как только скрипт запускается, он находит все задачи с
status_id==1
, что по дефолту соответствует новой задаче, и пробегая по всем задачам, обнаруживает те, у кого нет исполнителя:
getattr(issue, 'assigned_to', None)
. Если вам необходимо посмотреть вообще все задачи, то начало цикла примет вид
for issue in  redmine.issue.all():
и он переберет все задачи в принципе. Полезно попробовать так сделать, используя закомментированные выше print. Относительно полный список методов, функций и объектов либы можно найти на сайте ее разработчиков, который элементарно гуглится.

И как всегда, скрипты и текст в более удобном виде доступны у меня в вики - pubwiki.mindsellers.ru
#python #redmine #telegram
Здравствуйте, дорогие подписчики!

Не прошло и года, как на канале выходит новый пост. Но разочарую - он не будет посвящен очередному колдунству.

В компании, где я сейчас работаю, освободилась вакансия linux-админа. Работа полностью удаленная, график работы 5/2, но подразумевает периодические дежурства в режиме "не пропусти пиздец". Оформление полностью по ТК, предусмотрен испытательный срок. ЗП на испыталку - 60к. Основные задачи связаны с поддержкой текущей инфраструктуры(сервера арендованные, на них развернуто порядка 80 виртуальных машин, используется Proxmox), внедрением новых сервисов, обеспечением мониторинга(Zabbix)

Основные требования:
* опыт работы с Linux от 1 года
* понимание принципов работы основных сетевых сервисов(DNS, DHCP, VLAN, VPN и тд)
* опыт написания простых скриптов на bash и/или python
* ну там всякая исполнительность, ответственность и тд

Кому интересно - пишите в личку, лучше сразу присылать резюме

Резюме пока принимаются до вторника, уже во вторник буду списываться-созваниваться с желающими
Дорогие почему-то еще оставшиеся тут товарищи!

1. С наступившим на всех на нас 2022 годом
2. Адрес pubwiki.mindsellers.ru скоро будет деактивирован, останки вики переезжают на wiki.mindsellers.ru в связи со сменой сервака и хорошим настроением
3. За последние месяцы мне есть что добавить в вики, чем и займусь в ближайшее время
а вот вам и простенькое

Простая реализация любого API на Python и на коленке за 5 минут

В статье в основном код, не имеет смысла валить его сюда, так что милости прошу в вики

https://wiki.mindsellers.ru/index.php?title=%D0%9F%D1%80%D0%BE%D1%81%D1%82%D0%B0%D1%8F_%D1%80%D0%B5%D0%B0%D0%BB%D0%B8%D0%B7%D0%B0%D1%86%D0%B8%D1%8F_JSON-API_%D0%BD%D0%B0_Bottle

#python
Народ, ищу работу в качестве админа (не девопса) linux и/или телефонии на asterisk. Обширный опыт работы, в том числе в федеральном операторе связи и всероссийских торговых организациях. Естественно, удаленка. Хочется фултайм, но готов и на проектную/парт-тайм работу, статус самозанятого в наличии(даже сбис с ЭЦП есть). Кому есть что предложить - прошу в личку
Тони_Гэддис_Начинаем_программировать_на_Python_5_е_издание_2022.pdf
18.4 MB
Хорошая книжка

Попалась тут хорошая книжка по python. Прям совсем для начинающих. Рекомендую, в том числе и себе, заиметь ее в бумажном виде, но ценник откровенно конский, так что вот вам pdf.

Да, воровать нехорошо. Будем называть это параллельным импортом интеллектуальной собственности
К книжке прилагается архив со всеми примерами, а также видео по каждой главе(видео на английском, но автор говорит очень разборчиво)
Товарищи, кто плотно работал с nextcloud - прошу написать мне в личку, вопросик есть.
Коллеги, кто ещё остался тут из Самары! Требуется админ-универсал: и коннектор обжать, и несколько микротов настроить, и скулевую базу сдампить. Из минусов: надо еблом торговать в офисе и иногда нажимать эникей, а также изредка гонять в краткие командировки.
Есть возрастной ценз - до 35-40 лет - заморочь хозяина
Из плюсов: начальник конторы - сам бывший админ, и всю инфру строил он, работу лишнюю никто не выдумывает: всё работает - сиди кури бамбук. Цена вопроса обсуждаема. Резюме в личку сразу с зарплатными ожиданиями.