Привет всем, не знаю зачем это надо, но может кому пригодится…

Дисклеймер: Я ни в коем случае не являюсь профессиональным Си программистом.

Что нам понадобится:

1. Любой компьютер на Linux, Ubuntu, Centos, MacOS… с доступом к порту 443 или 8443 из интернета.
2. Любой Си компилятор
3. Бибилиотеки openssl, libssl-dev («apt-get install openssl libssl-dev» в терминале, для Ubuntu)

Итак, приступим…

Первое что нужно сделать — это создать бота у отца всех ботов @BotFather, опустим все подробности и предположим что с этой задачей все справились и получили токен, что-то вроде:
373288854:AAHHT77v5_ZNEMус4bfnРЩo6dxiMeeEwgwJ

Далее создадим ssl сертификат, для установки WebHook. Команда выглядит примерно так:

openssl req -newkey rsa:2048 -sha256 -nodes -keyout private.key -x509 -days 365 -out public.pem

Упакуем ключ и публичный сертификат в один файл:

cat private.key public.pem > cert.pem

Устанавливаем WebHook:

curl -F"url=https://ВАШ_IP:ПОРТ(либо 443, либо 8443)/ЛЮБОЙ_URI(можно и без него, я буду использовать токен)/" -F"certificate=@public.pem" https://api.telegram.org/botТОКЕН/setWebhook/

Должен прийти JSON ответ что-то типа success:true..., если нет то проверьте все и попробуйте еще раз.

Приступаем к самому интересному:

Создаем файл main.c и открываем его в любом редакторе. Включаем необходимые библиотеки:

#include <stdio.h>
#include <openssl/bio.h>
#include <openssl/ssl.h>
#include <unistd.h>
#include <openssl/err.h>
#include <netinet/in.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <resolv.h>
#include <netdb.h>

Функция инициализации сокета:

int InitializeSocket(int port) {
    int sd = socket(AF_INET, SOCK_STREAM, 0); 
    if (sd < 0) exit(-1);
    struct sockaddr_in s_addr;
    s_addr.sin_family = AF_INET;
    s_addr.sin_addr.s_addr = INADDR_ANY;
    s_addr.sin_port = htons(port);
    if (bind(sd, (struct sockaddr *)&s_addr, sizeof(s_addr)) < 0) {
        printf("Binding Error!\n");
        exit(-3);
    }
    return sd;
}

Включаем SSL/TLS:

SSL_CTX * InitializeSSL(char[] certificate) {
    OpenSSL_add_all_algorithms();
    SSL_load_error_strings();
    SSL_library_init();
    SSL_CTX * sslctx = SSL_CTX_new(TLSv1_2_server_method());
    if (SSL_CTX_use_certificate_file(sslctx, certificate , SSL_FILETYPE_PEM) <= 0) {
        exit(-2);
    }
    if (SSL_CTX_use_PrivateKey_file(sslctx,  certificate, SSL_FILETYPE_PEM) <= 0) {
        exit(-2);
    }
    if (!SSL_CTX_check_private_key(sslctx)) {
       exit(-2);
    }
    return sslctx;
}

Собственно сам main():

int main() {
    SSL_CTX * sslctx = InitializeSSL("cert.pem"); //Созданный нами файл из приватного ключа и публичного сертификата
    int sd = InitializeSocket(8443);  //Порт который вы указали при установке WebHook
    listen(sd, 5); //Слушаем подключения на созданном сокете
    while (1) { //Запускаем бесконечный цикл
        int client = accept(sd, NULL, NULL) //функция accept ждет новое подключение, в качестве параметров принимает сокет, указатель на структуру sockaddr, и указатель на размер этой структуры и записывает туда данные подключения, так как нам необязательно знать подробности подключения отправим NULL, функция возвращает сетевой дескриптор. 
        SSL * ssl = SSL_new(sslctx); //Cоздаем ssl дескриптор
        SSL_set_fd(ssl, client); //Переключаем обычный дескриптор на защищенный 
        if (SSL_accept(ssl) <= 0) { //Пытаемся принять подключение, если ошибка то закрываем соединение и возвращаемся к началу цикла
            SSL_clear(ssl);
            close(newsd);
            continue;
        }
        //Для увеличения производительности будем использовать fork() и обрабатывать соединение в дочернем процессе, а родительский процесс вернем к ожиданию новых подключений
     int pid = fork();
        if (pid != 0) { //Если это родитель, то закрываем подключение и возвращаемся к началу цикла
            SSL_clear(ssl);
            close(newsd);
            continue;
        }
    //Дальнейшие действия будут происходить в дочернем процессе
   //Опишу их дальше после некоторых пояснений....
    exit(0); //Завершаем дочерний процесс
    }
}

Так как Telegram использует HTTP протокол поясню некоторые моменты:

Любой HTTP запрос состоит из заголовков отделенных между собой "\r\n", и тела отделенного от заголовков "\r\n\r\n", может быть пустым, но разделитель "\r\n\r\n" присутствует всегда. Запросы от Telegram будут приходить методом POST, тело будет в формате JSON.

Пример запроса похожего на Telegram:

POST /(URI указанный при установке WebHook) HTTP/1.1\r\n
....Неважные для нас поля заголовков
Content-Type: application/json\r\n (Тип данных в теле)
Content-Length: 256\r\n (Размер тела в байтах, целое число)
..../r/n/r/n
Json тело

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

Для этого подготовим короткий HTTP response:

HTTP/1.1 200 OK\r\n
Connection: close\r\n\r\n

Этих двух полей достаточно что бы сказать серверу Telegram что все нормально, ответ 200 и можно закрывать соединение

Продолжаем писать программу. Внутри цикла после создания дочернего процесса…

char[] response = "HTTP/1.1 200 OK\r\nConnection: close\r\n\r\n"; //Наш HTTP response
char header[1024];
bzero(header,1024); //Выделили массив для записи в него заголовков запроса и на всякий случай занулили там все записи.
int s = 0;
int n = 0;
while (strcmp(header + s - strlen("\r\n\r\n"), "\r\n\r\n") != 0) { //strcmp Сравнивает две строки и если они равны возвращает 0, в нашем случае сравниваем последние strlen("\r\n\r\n") байт с "\r\n\r\n", то есть ищем конец заголовка
    n = SSL_read(ssl,header+s,1); //Считываем данные по одному байту в header + s, s - общее кол-во считанных байт
    s += n; //n - кол-во считанных байт за раз
}
//Все, заголовки считаны, теперь нам надо проверить метод, uri, content-type и вытащить content-length запроса.
if (strstr(header,"POST /(URI указанный при установке WebHook) HTTP/1.1\r\n") == NULL) { //Ищем вхождение строки POST .... в header, если его нет то возвращается NULL, значит пришел неверный запрос, закрываем подключение и завершаем дочерний процесс
            SSL_clear(ssl);
            close(client);
            exit(0);
}
//Также проверим тип данных, должен быть application/json;
       if (strstr(header, "Content-Type: application/json") == NULL) {
            SSL_clear(ssl);
            close(client);
            exit(0);
        }
//Если все нормально, то узнаем размер тела
int len = atoi(strstr(header, "Content-Length: ") + strlen("Content-Length: ")); //strstr возвращает указатель не первое вхождение указанной строки, то есть на "Content-Length: ", а кол-во байт записано дальше после этой строки, поэтому прибавляем длину строки "Content-Length: " и приводим строку к типу int функцией atoi(char *);

char body[len+2]; 
bzero(body, len+2); //Создаем массив для тела, на этот раз мы точно знаем сколько байт нам понадобится, но создаем с запасом, дабы не оказалось что в памяти сразу после нашей строки что-то записано
 n = 0;
 s = 0;
 while (len - s > 0) { //Так как мы четко знаем сколько данных нам надо считать просто считываем пока не считаем нужное кол-во
    n = SSL_read(ssl, request + s, len - s); //Конечно можно было считать целиком все данные, но бывают случаи при плохом соединении, за раз все данные не считываеются, и функция SSL_read возвращает кол-во считанных байт
    s += n;
}
//На этом получение данных окончено, отправим наш http response и закроем соединение SSL_write(ssl, response, (int)strlen(response));
SSL_clear(ssl);
SSL_free(ssl);
close(client);
//Так как у нас "Hello, World" бот то мы будем просто отвечать на любое сообщение "Hello, World!", но нам нужно знать кому отправлять сообщение для это из тела запросы надо вытащить параметр chat_id
int chat_id = atoi(strstr("\"chat_id\":") + strlen("\"chat_id\":")); //То же самое что и с Content-Length
//Осталось только отправить сообщение, для этого лучше создадим отдельную функцию SendMessage
char msg[] = "Hello, World!";
SendMessage(chat_id, msg); //Описание функции далее

Для отправки запросов нам почти так же понадобится инициализировать сокет и ssl, но в отличие от получения запросов, мы не будем ждать подключения а просто сразу будем отправлять данные:

void SendMessage(int chat_id, char[] msg) {
    int port = 443;
    char host[] =  "api.telegram.org"; //Адрес и порт всегда одинаковые
   //Создадим шаблон HTTP запроса для отправки сообщения, в виде форматированной строки
    char header[] = "POST /bot352115436:AAEAIEPeKdR2-SS7p9jGeksQljkNa9_Smo0/sendMessage HTTP/1.1\r\nHost: files.ctrl.uz\r\nContent-Type: application/json\r\nContent-Length: %d\r\nConnection: close\r\n\r\n%s";
    //Шаблон тела для отправки сообщения
    char tpl[] = "{\"chat_id\":%d,\"text\":\"%s\"}";
    char body[strlen(tpl)+strlen(msg)+16];
    bzero(body, strlen(tpl)+strlen(msg)+16);
    sprintf(body,tpl,chat_id,msg); //Как printf, только печатаем в char[] 
    char request[strlen(header)+strlen(body)+4];
    bzero(request,strlen(header)+strlen(body)+4);
    sprintf(request, header, strlen(body), body);
   //Подготовили наш запрос, теперь создаем подключение
    struct hostent *server; 
    struct sockaddr_in serv_addr;
    int sd;
    sd = socket(AF_INET, SOCK_STREAM, 0);
    if (sd < 0) exit(-5);
    server = gethostbyname(host); //Данная функция получает ip и еще некоторые данные по url
    if (server == NULL) exit(-6); 
    bzero(&serv_addr, sizeof(serv_addr));
    serv_addr.sin_family = AF_INET;
    serv_addr.sin_port = htons(portno);
    memcpy(&serv_addr.sin_addr.s_addr,server->h_addr,server->h_length); 
    if (connect(sd,(struct sockaddr *)&serv_addr,sizeof(serv_addr)) < 0) { exit(-6);}
    SSL_CTX * sslctx = SSL_CTX_new(TLSv1_client_method());
    SSL * cSSL = SSL_new(sslctx);
    SSL_set_fd(cSSL, sfd);
    SSL_connect(cSSL); 
    SSL_write(cSSL,request,(int)strlen(request)); //Отправляем наш запрос, в идеале его надо отправлять так же как мы считывали данные, то есть с проверкой на кол-во отправленных байт
    char str[1024];
    SSL_read(cSSL, str, 1024); //Считываем ответ и закрываем соединение
    SSL_clear(cSSL);
    SSL_CTX_free(sslctx);
    close(sd);
}

На этом, в принципе все. Сохраняем файл в одной папке с сертификатом, компилируем любым компилятором и запускаем:

clang main.c -o bot -lcrypto -lssl 
./bot

Конец!

Надеюсь статья будет кому-то полезной.
Поделиться с друзьями
-->

Комментарии (26)


  1. lair
    06.04.2017 16:02

    А для C правда нет ни одной библиотеки для работы с HTTP?


    1. usmanov_amir
      06.04.2017 16:07

      Скорее всего есть, но в работе с HTTP ничего трудного не вижу, поэтому смысла использовать библиотеки не вижу


      1. lair
        06.04.2017 16:16
        +5

        Ну да, совсем ничего трудного: стриминга/чанкинга не существует, енкодинга не существует… Вот пришлют вам запрос c Content-Length: find for yourself — и развлекайтесь себе. А еще в ответ на некорректный контент-тайп надо отвечать 400 или 415. Ну и так далее.


        1. usmanov_amir
          06.04.2017 16:23

          Так я и не писал полноценный http сервер, а только для работы с сервером telegram, у которого строгая спецификация. Я не спорю, даже тут некоторых моментов не хватает.


          1. lair
            06.04.2017 16:26
            +1

            Так я и не писал полноценный http сервер, а только для работы с сервером telegram, у которого строгая спецификация.

            Вот только вы не знаете, что к вам обращается именно сервер telegram. И того, что он всегда будет поддерживать спецификацию — тоже. И того, что между вами нет прокси. И еще пятнадцати вещей.


            Поэтому всегда соблюдайте спецификацию. По крайней мере в части корректной обработки того, что к вам присылают.


            1. usmanov_amir
              06.04.2017 16:36
              -1

              Я не понимаю чего Вы добиваетесь?
              Тут все левые запросы отпадают уже при проверке запроса POST и ссылки которая поедставляет из себя токен, дальше просто проверяется Content-Length и считываются уже сами данные. Да, согласен, прокси может сжать данные в gzip, да тогда прога, вероятнее всего, упадет, правда упадет только дочерний процесс, а с нормальными запросами сервак продолжит работать


              1. lair
                06.04.2017 16:44

                Я не понимаю чего Вы добиваетесь?

                Отсутствия велосипедов.


                Тут все левые запросы отпадают уже при проверке запроса POST и ссылки которая поедставляет из себя токен,

                Этот запрос может прислать кто угодно. В буквальном смысле слова.


                упадет только дочерний процесс

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


                И это мы еще не стали обсуждать ручной парсинг JSON с помощью strstr.


                1. usmanov_amir
                  06.04.2017 17:25
                  -2

                  Так, во-первых прочитайте дисклеймер в начале статьи. А во-вторых серваки apache, nginx и подобные работают по тому же принципу, для каждого соединения отдельный процесс, с одним лишь отличием, процессы там создается заранее, а потом передаются туда данные, так как создание процесса занимает относительно длительное время. Если Вы хотите показать на мою неграмотность в программировании, то не должны судить по одной статье. Текст и так получился достаточно длинным, а если я бы учитывал все тонкости, статья бы получилась слишком громоздкой. Я согласен со всеми Вашими замечаниями, но для Hello, World бота Вы предъявляете слишком высокие тоебования, поэтому прошу закончить эту демагогию.


                  1. lair
                    06.04.2017 17:28
                    -1

                    Так, во-первых прочитайте дисклеймер в начале статьи.

                    Это повод игнорировать замечания?


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

                    Имеенно. А есть сервера, которые не создают процесс на каждое соединение (именно потому, что это дорого).


                    Если Вы хотите показать на мою неграмотность в программировании, то не должны судить по одной статье.

                    Я ничего не хочу сказать о вашей неграмотности, я говорю о неграмотности решения в статье.


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

                    … а если бы вы взяли готовое решение, то тонкости бы учитывало оно, а не вы. О чем и речь.


              1. iSage
                06.04.2017 16:48
                +6

                Серьезно? Отпадают?
                Расскажите, а что будет с вашим

                char header[1024];
                

                если нигде в запросе не встретится \r\n\r\n и запрос будет больше 1024 байт?


  1. lair
    06.04.2017 16:56

    А, да.


    char header[] = "POST /bot352115436:AAEAIEPeKdR2-SS7p9jGeksQljkNa9_Smo0/sendMessage HTTP/1.1\r\nHost: files.ctrl.uz\r\nContent-Type: application/json\r\nContent-Length: %d\r\nConnection: close\r\n\r\n%s";

    Круто же. Любой уважающий себя сервер (кроме files.ctrl.uz) мог бы и послать.


    1. usmanov_amir
      06.04.2017 17:14
      -1

      Токен не настоящий, к Вашему сведению


      1. lair
        06.04.2017 17:26

        Да токен-то ни при чем, в общем-то. Это просто иллюстрация к "HTTP — это просто".


  1. mihmig
    06.04.2017 18:31
    +1

    Когда я вижу решение задачи на C — ожидаю, что
    1. Нужна очень высокая скорость работы, недостижимая на скриптовых и VM-языках
    В случае с телеграм-ботом не требуется — мы скорее упрёмся в ограничение самого телеграма (примерно не более 30 сообщений/с.)
    2. Работаем на оборудовании с очень ограниченными ресурсами.
    Хм, сейчас даже роутер на openwrt спокойно тянет простенькие PHP/Python-скрипты. дешевле написать на них телеграм бота, логику которого гораздо легче поддерживать.

    3. Совершенно не упомянуто очень важное свойство телеграма — можно не просто ответить 200 OK — а сразу передать ответ!


  1. Indever2
    06.04.2017 19:11

    Познавательная статья, спасибо. Думаю, в скором времени она мне пригодиться на практике.
    В код не вдавался, скорее в порядок действий, но успел заметить использование функции bzero.
    Из мана:

    4.3BSD. This function is deprecated (marked as LEGACY in
    POSIX.1-2001): use memset(3) in new programs.

    Мемсет — наше спасение :)


  1. AterCattus
    07.04.2017 10:54
    -1

    Ну и код. Он больше запутывает, чем дает какую-то пользу.


  1. iChaos
    07.04.2017 11:13
    +1

    Хороший пример того, как не надо писать программы…


    1. Indever2
      07.04.2017 12:16

      В самом начале статьи автор предупредил, что не является профессиональным си-программистом.
      Поэтому, кмк, профит данной в статьи именно в алгоритме действий.
      У тех, кто в первый раз с этим столкнулся перед глазами будет рабочий пример:)


      1. lair
        07.04.2017 12:31
        -1

        Эмм, алгоритме каких действий?


        1. Indever2
          07.04.2017 12:35

          Например, я вообще не писал ботов.
          Теперь мне стало ясно, как работать с апи, предоставляемым телеграммом.
          Я был приятно удивлен тем, что можно оформить это в виде простого сетевого приложения (у меня уже целая библитека функций для приложений на сокетах).


          1. lair
            07.04.2017 12:38

            Теперь мне стало ясно, как работать с апи, предоставляемым телеграммом.

            … и как же, если не секрет?


            Я был приятно удивлен тем, что можно оформить это в виде простого сетевого приложения

            Ну да, http-сервер — это "простое сетевое приложение", что в этом удивительного?


            1. Indever2
              07.04.2017 14:12

              … и как же, если не секрет?
              Про сертификат до этой статьи я был ни сном, ни духом :)

              Ну да, http-сервер — это «простое сетевое приложение», что в этом удивительного?
              Последние пару месяцев я моими тасками было ковыряться в сорцах udhcpc и udhcpd, расширяя его функционал.
              После всего этого то, что представлено тут, воспринимается очень просто :)


              1. lair
                07.04.2017 14:13
                -2

                Про сертификат до этой статьи я был ни сном, ни духом

                Это не отвечает на вопрос "как работать с апи, предоставляемым телеграмом".


                После всего этого то, что представлено тут, воспринимается очень просто

                … просто — не значит правильно.


          1. lair
            07.04.2017 12:40

            Теперь мне стало ясно, как работать с апи, предоставляемым телеграммом.

            Вот, например, простой вопрос: как убедиться, что запрос, присланный на вебхук, пришел именно от телеграма?


            1. Indever2
              07.04.2017 14:17

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

              Если этот вариант не проканает, то можно воспользоваться RAW-сокетами — тогда можно будет распарсить сырой пакет полностью и посмотреть заголовки.
              После этого — сравнить сертефикаты.


              1. lair
                07.04.2017 14:25

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

                И что делать с этим адресом?


                Если этот вариант не проканает, то можно воспользоваться RAW-сокетами — тогда можно будет распарсить сырой пакет полностью и посмотреть заголовки. После этого — сравнить сертефикаты.

                Сравнить какой сертификат с чем?