From cc45fb52093f850c17cd782ea43348b5db91ac2b Mon Sep 17 00:00:00 2001 From: Hamcha Date: Tue, 3 Apr 2018 11:51:06 +0200 Subject: [PATCH] Move broker + lib to its own repo --- README.md | 3 + api.go | 154 ++++++++++++++++++++++++++ broker.go | 166 ++++++++++++++++++++++++++++ client.go | 64 +++++++++++ client_test.go | 16 +++ cmd/tg-broker/action.go | 27 +++++ cmd/tg-broker/clients.go | 79 ++++++++++++++ cmd/tg-broker/main.go | 57 ++++++++++ cmd/tg-broker/telegram.go | 220 ++++++++++++++++++++++++++++++++++++++ cmd/tg-broker/webhook.go | 33 ++++++ command.go | 103 ++++++++++++++++++ 11 files changed, 922 insertions(+) create mode 100644 README.md create mode 100644 api.go create mode 100644 broker.go create mode 100644 client.go create mode 100644 client_test.go create mode 100644 cmd/tg-broker/action.go create mode 100644 cmd/tg-broker/clients.go create mode 100644 cmd/tg-broker/main.go create mode 100644 cmd/tg-broker/telegram.go create mode 100644 cmd/tg-broker/webhook.go create mode 100644 command.go diff --git a/README.md b/README.md new file mode 100644 index 0000000..1850c28 --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# Bot API for Telegram bots + +Should work if you can put the time to figure out how it works (check client_test.go or better yet, [clessy](https://github.com/hamcha/clessy)) \ No newline at end of file diff --git a/api.go b/api.go new file mode 100644 index 0000000..274d291 --- /dev/null +++ b/api.go @@ -0,0 +1,154 @@ +package tg + +// APIUser represents the "User" JSON structure +type APIUser struct { + UserID int64 `json:"id"` + FirstName string `json:"first_name"` + LastName string `json:"last_name,omitempty"` + Username string `json:"username,omitempty"` +} + +// ChatType defines the type of chat +type ChatType string + +const ( + // ChatTypePrivate is a private chat (between user and bot) + ChatTypePrivate ChatType = "private" + + // ChatTypeGroup is a group chat (<100 members) + ChatTypeGroup ChatType = "group" + + // ChatTypeSupergroup is a supergroup chat (>=100 members) + ChatTypeSupergroup ChatType = "supergroup" + + // ChatTypeChannel is a channel (Read-only) + ChatTypeChannel ChatType = "channel" +) + +// APIChat represents the "Chat" JSON structure +type APIChat struct { + ChatID int64 `json:"id"` + Type ChatType `json:"type"` + Title *string `json:"title,omitempty"` + Username *string `json:"username,omitempty"` + FirstName *string `json:"first_name,omitempty"` + LastName *string `json:"last_name,omitempty"` +} + +// APIMessage represents the "Message" JSON structure +type APIMessage struct { + MessageID int64 `json:"message_id"` + User APIUser `json:"from"` + Time int64 `json:"date"` + Chat *APIChat `json:"chat"` + FwdUser *APIUpdate `json:"forward_from,omitempty"` + FwdTime *int `json:"forward_date,omitempty"` + ReplyTo *APIMessage `json:"reply_to_message,omitempty"` + Text *string `json:"text,omitempty"` + Audio *APIAudio `json:"audio,omitempty"` + Document *APIDocument `json:"document,omitempty"` + Photo []APIPhotoSize `json:"photo,omitempty"` + Sticker *APISticker `json:"sticker,omitempty"` + Video *APIVideo `json:"video,omitempty"` + Voice *APIVoice `json:"voice,omitempty"` + Caption *string `json:"caption,omitempty"` + Contact *APIContact `json:"contact,omitempty"` + Location *APILocation `json:"location,omitempty"` + NewUser *APIUser `json:"new_chat_partecipant,omitempty"` + LeftUser *APIUser `json:"left_chat_partecipant,omitempty"` + PhotoDeleted *bool `json:"delete_chat_photo,omitempty"` + GroupCreated *bool `json:"group_chat_created,omitempty"` + SupergroupCreated *bool `json:"supergroup_chat_created,omitempty"` + ChannelCreated *bool `json:"channel_chat_created,omitempty"` + GroupToSuper *int64 `json:"migrate_to_chat_id,omitempty"` + GroupFromSuper *int64 `json:"migrate_from_chat_id,omitempty"` +} + +// APIPhotoSize represents the "PhotoSize" JSON structure +type APIPhotoSize struct { + FileID string `json:"file_id"` + Width int `json:"width"` + Height int `json:"height"` + FileSize *int `json:"file_size,omitempty"` +} + +// APIAudio represents the "Audio" JSON structure +type APIAudio struct { + FileID string `json:"file_id"` + Duration int `json:"duration"` + Performer *string `json:"performer,omitempty"` + Title *string `json:"title,omitempty"` + MimeType *string `json:"mime_type,omitempty"` + FileSize *int `json:"file_size,omitempty"` +} + +// APIDocument represents the "Document" JSON structure +type APIDocument struct { + FileID string `json:"file_id"` + Thumbnail *APIPhotoSize `json:"thumb,omitempty"` + Filename string `json:"file_name"` + MimeType *string `json:"mime_type,omitempty"` + FileSize *int `json:"file_size,omitempty"` +} + +// APISticker represents the "Sticker" JSON structure +type APISticker struct { + FileID string `json:"file_id"` + Width int `json:"width"` + Height int `json:"height"` + Thumbnail *APIPhotoSize `json:"thumb,omitempty"` + FileSize *int `json:"file_size,omitempty"` +} + +// APIVideo represents the "Video" JSON structure +type APIVideo struct { + FileID string `json:"file_id"` + Width int `json:"width"` + Height int `json:"height"` + Duration int `json:"duration"` + Thumbnail *APIPhotoSize `json:"thumb,omitempty"` + MimeType *string `json:"mime_type,omitempty"` + FileSize *int `json:"file_size,omitempty"` +} + +// APIVoice represents the "Voice" JSON structure +type APIVoice struct { + FileID string `json:"file_id"` + Duration int `json:"duration"` + MimeType *string `json:"mime_type,omitempty"` + FileSize *int `json:"file_size,omitempty"` +} + +// APIContact represents the "Contact" JSON structure +type APIContact struct { + PhoneNumber string `json:"phone_number"` + FirstName string `json:"first_name"` + LastName *string `json:"last_name,omitempty"` + UserID *int64 `json:"user_id,omitempty"` +} + +// APILocation represents the "Location" JSON structure +type APILocation struct { + Longitude float64 `json:"longitude"` + Latitude float64 `json:"latitude"` +} + +// APIUpdate represents the "Update" JSON structure +type APIUpdate struct { + UpdateID int64 `json:"update_id"` + Message APIMessage `json:"message"` +} + +// APIFile represents the "File" JSON structure +type APIFile struct { + FileID string `json:"file_id"` + Size *int `json:"file_size,omitempty"` + Path *string `json:"file_path,omitempty"` +} + +// APIResponse represents a response from the Telegram API +type APIResponse struct { + Ok bool `json:"ok"` + ErrCode *int `json:"error_code,omitempty"` + Description *string `json:"description,omitempty"` +} diff --git a/broker.go b/broker.go new file mode 100644 index 0000000..8ed3e3e --- /dev/null +++ b/broker.go @@ -0,0 +1,166 @@ +package tg + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "log" + "net" +) + +// Broker is a broker connection handler with callback management functions +type Broker struct { + Socket net.Conn + Callbacks []BrokerCallback + + cbFree int +} + +// ConnectToBroker creates a Broker connection +func ConnectToBroker(brokerAddr string) (*Broker, error) { + sock, err := net.Dial("tcp", brokerAddr) + if err != nil { + return nil, err + } + + broker := new(Broker) + broker.Socket = sock + broker.Callbacks = make([]BrokerCallback, 0) + broker.cbFree = 0 + return broker, nil +} + +// Close closes a broker connection +func (b *Broker) Close() { + b.Socket.Close() +} + +// SendTextMessage sends a HTML-styles text message to a chat. +// A reply_to message ID can be specified as optional parameter. +func (b *Broker) SendTextMessage(chat *APIChat, text string, original *int64) { + b.sendCmd(ClientCommand{ + Type: CmdSendTextMessage, + TextMessageData: &ClientTextMessageData{ + Text: text, + ChatID: chat.ChatID, + ReplyID: original, + }, + }) +} + +// SendPhoto sends a photo with an optional caption to a chat. +// A reply_to message ID can be specified as optional parameter. +func (b *Broker) SendPhoto(chat *APIChat, data []byte, filename string, caption string, original *int64) { + b.sendCmd(ClientCommand{ + Type: CmdSendPhoto, + PhotoData: &ClientPhotoData{ + ChatID: chat.ChatID, + Filename: filename, + Bytes: base64.StdEncoding.EncodeToString(data), + Caption: caption, + ReplyID: original, + }, + }) +} + +// ForwardMessage forwards a message between chats. +func (b *Broker) ForwardMessage(chat *APIChat, message APIMessage) { + b.sendCmd(ClientCommand{ + Type: CmdForwardMessage, + ForwardMessageData: &ClientForwardMessageData{ + ChatID: chat.ChatID, + FromChatID: message.Chat.ChatID, + MessageID: message.MessageID, + }, + }) +} + +// SendChatAction sets a chat action for 5 seconds or less (canceled at first message sent) +func (b *Broker) SendChatAction(chat *APIChat, action ChatAction) { + b.sendCmd(ClientCommand{ + Type: CmdSendChatAction, + ChatActionData: &ClientChatActionData{ + ChatID: chat.ChatID, + Action: action, + }, + }) +} + +// GetFile sends a file retrieval request to the Broker. +// This function is asynchronous as data will be delivered to the given callback. +func (b *Broker) GetFile(fileID string, fn BrokerCallback) int { + cid := b.RegisterCallback(fn) + b.sendCmd(ClientCommand{ + Type: CmdGetFile, + FileRequestData: &FileRequestData{ + FileID: fileID, + }, + Callback: &cid, + }) + return cid +} + +// RegisterCallback assigns a callback ID to the given callback and puts it on the callback list. +// This function should never be called by clients. +func (b *Broker) RegisterCallback(fn BrokerCallback) int { + cblen := len(b.Callbacks) + // List is full, append to end + if b.cbFree == cblen { + b.Callbacks = append(b.Callbacks, fn) + b.cbFree++ + return cblen + } + // List is not full, use empty slot and find next one + id := b.cbFree + b.Callbacks[id] = fn + next := b.cbFree + 1 + for ; next < cblen; next++ { + if b.Callbacks[next] == nil { + break + } + } + b.cbFree = next + return id +} + +// RemoveCallback removes a callback from the callback list by ID. +// This function should never be called by clients. +func (b *Broker) RemoveCallback(id int) { + b.Callbacks[id] = nil + if id < b.cbFree { + b.cbFree = id + } + b.resizeCbArray() +} + +// SpliceCallback retrieves a callback by ID and removes it from the list. +// This function should never be called by clients. +func (b *Broker) SpliceCallback(id int) BrokerCallback { + defer b.RemoveCallback(id) + return b.Callbacks[id] +} + +func (b *Broker) sendCmd(cmd ClientCommand) { + data, err := json.Marshal(cmd) + if err != nil { + log.Printf("[sendCmd] JSON Encode error: %s\n", err.Error()) + } + fmt.Fprintln(b.Socket, string(data)) +} + +func (b *Broker) resizeCbArray() { + var i int + cut := false + for i = len(b.Callbacks); i > 0; i-- { + if b.Callbacks[i-1] != nil { + break + } + cut = true + } + if cut { + b.Callbacks = b.Callbacks[0:i] + if b.cbFree > i { + b.cbFree = i + } + } +} diff --git a/client.go b/client.go new file mode 100644 index 0000000..af3002b --- /dev/null +++ b/client.go @@ -0,0 +1,64 @@ +package tg + +import ( + "bufio" + "encoding/json" + "io" + "log" +) + +// UpdateHandler is an update handler for webhook updates +type UpdateHandler func(broker *Broker, message APIMessage) + +// BrokerCallback is a callback for broker responses to client requests +type BrokerCallback func(broker *Broker, update BrokerUpdate) + +// CreateBrokerClient creates a connection to a broker and sends all webhook updates to a given function. +// This is the intended way to create clients, please refer to examples for how to make a simple client. +func CreateBrokerClient(brokerAddr string, updateFn UpdateHandler) error { + broker, err := ConnectToBroker(brokerAddr) + if err != nil { + return err + } + defer broker.Close() + + return RunBrokerClient(broker, updateFn) +} + +// RunBrokerClient is a slimmer version of CreateBrokerClient for who wants to keep its own broker connection +func RunBrokerClient(broker *Broker, updateFn UpdateHandler) error { + in := bufio.NewReader(broker.Socket) + var buf []byte + for { + bytes, isPrefix, err := in.ReadLine() + if err != nil { + break + } + buf = append(buf, bytes...) + + if isPrefix { + continue + } + + var update BrokerUpdate + err = json.Unmarshal(buf, &update) + if err != nil { + log.Printf("[tg - CreateBrokerClient] ERROR reading JSON: %s\r\n", err.Error()) + log.Printf("%s\n", string(buf)) + continue + } + + if update.Callback == nil { + // It's a generic message: dispatch to UpdateHandler + go updateFn(broker, *(update.Message)) + } else { + // It's a response to a request: retrieve callback and call it + go broker.SpliceCallback(*(update.Callback))(broker, update) + } + + // Empty buffer + buf = []byte{} + } + + return io.EOF +} diff --git a/client_test.go b/client_test.go new file mode 100644 index 0000000..5cd4dd9 --- /dev/null +++ b/client_test.go @@ -0,0 +1,16 @@ +package tg_test + +// This example creates a basic client that connects to a broker and checks for message containing greetings. +// If it finds a greeting message it will greet back the user (using the reply_to parameter) +func ExampleCreateBrokerClient() { + CreateBrokerClient("localhost:7314", func(broker *Broker, message APIMessage) { + // Check if it's a text message + if message.Text != nil { + // Check that it's a greeting + if *(message.Text) == "hello" || *(message.Text) == "hi" { + // Reply with a greeting! + broker.SendTextMessage(message.Chat, "Hello!", message.MessageID) + } + } + }) +} diff --git a/cmd/tg-broker/action.go b/cmd/tg-broker/action.go new file mode 100644 index 0000000..826b6a6 --- /dev/null +++ b/cmd/tg-broker/action.go @@ -0,0 +1,27 @@ +package main + +import ( + "net" + + "github.com/hamcha/clessy/tg" +) + +func executeClientCommand(action tg.ClientCommand, client net.Conn) { + switch action.Type { + case tg.CmdSendTextMessage: + data := *(action.TextMessageData) + api.SendTextMessage(data) + case tg.CmdGetFile: + data := *(action.FileRequestData) + api.GetFile(data, client, *action.Callback) + case tg.CmdSendPhoto: + data := *(action.PhotoData) + api.SendPhoto(data) + case tg.CmdForwardMessage: + data := *(action.ForwardMessageData) + api.ForwardMessage(data) + case tg.CmdSendChatAction: + data := *(action.ChatActionData) + api.SendChatAction(data) + } +} diff --git a/cmd/tg-broker/clients.go b/cmd/tg-broker/clients.go new file mode 100644 index 0000000..d93687f --- /dev/null +++ b/cmd/tg-broker/clients.go @@ -0,0 +1,79 @@ +package main + +import ( + "bufio" + "encoding/json" + "fmt" + "log" + "net" + + "github.com/hamcha/clessy/tg" +) + +var clients []net.Conn + +func startClientsServer(bind string) { + listener, err := net.Listen("tcp", bind) + assert(err) + + // Accept loop + for { + c, err := listener.Accept() + if err != nil { + log.Printf("Can't accept client: %s\n", err.Error()) + continue + } + clients = append(clients, c) + go handleClient(c) + } +} + +func handleClient(c net.Conn) { + b := bufio.NewReader(c) + defer c.Close() + + // Start reading messages + buf := make([]byte, 0) + for { + bytes, isPrefix, err := b.ReadLine() + if err != nil { + break + } + buf = append(buf, bytes...) + if isPrefix { + continue + } + + // Get command + var cmd tg.ClientCommand + err = json.Unmarshal(buf, &cmd) + if err != nil { + log.Printf("[handleClient] Can't parse JSON: %s\r\n", err.Error()) + log.Printf("%s\n", string(buf)) + continue + } + + // Empty buffer + buf = []byte{} + + executeClientCommand(cmd, c) + } + removeCon(c) +} + +func removeCon(c net.Conn) { + for i, con := range clients { + if c == con { + clients = append(clients[:i], clients[i+1:]...) + } + } +} + +func broadcast(message string) { + for _, c := range clients { + _, err := fmt.Fprintln(c, message) + if err != nil { + removeCon(c) + } + } +} diff --git a/cmd/tg-broker/main.go b/cmd/tg-broker/main.go new file mode 100644 index 0000000..df2469e --- /dev/null +++ b/cmd/tg-broker/main.go @@ -0,0 +1,57 @@ +package main + +import ( + "encoding/json" + "flag" + "log" + "net/http" + "os" +) + +// The Config data (parsed from JSON) +type Config struct { + BindServer string /* Address:Port to bind for Telegram */ + BindClients string /* Address:Port to bind for clients */ + Token string /* Telegram bot token */ + BaseURL string /* Base URL for webhook */ + WebhookURL string /* Webhook URL */ +} + +func assert(err error) { + if err != nil { + panic(err) + } +} + +var api *Telegram + +func main() { + cfgpath := flag.String("config", "config.json", "Path to configuration file") + flag.Parse() + + file, err := os.Open(*cfgpath) + assert(err) + + var config Config + err = json.NewDecoder(file).Decode(&config) + assert(err) + + // Create Telegram API object + api = mkAPI(config.Token) + + // Setup webhook handler + go func() { + log.Println("Starting webserver..") + http.HandleFunc(config.WebhookURL, webhook) + err := http.ListenAndServe(config.BindServer, nil) + assert(err) + }() + + // Register webhook @ Telegram + log.Println("Registering webhook..") + api.SetWebhook(config.BaseURL + config.WebhookURL) + + // Create server for clients + log.Println("Starting clients server..") + startClientsServer(config.BindClients) +} diff --git a/cmd/tg-broker/telegram.go b/cmd/tg-broker/telegram.go new file mode 100644 index 0000000..cafcc03 --- /dev/null +++ b/cmd/tg-broker/telegram.go @@ -0,0 +1,220 @@ +package main + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io/ioutil" + "log" + "mime/multipart" + "net" + "net/http" + "net/url" + "strconv" + + "github.com/hamcha/clessy/tg" +) + +// APIEndpoint is Telegram's current Bot API base url endpoint +const APIEndpoint = "https://api.telegram.org/" + +// Telegram is the API client for the Telegram Bot API +type Telegram struct { + Token string +} + +// mkAPI creates a Telegram instance from a Bot API token +func mkAPI(token string) *Telegram { + tg := new(Telegram) + tg.Token = token + return tg +} + +// SetWebhook sets the webhook address so that Telegram knows where to send updates +func (t Telegram) SetWebhook(webhook string) { + resp, err := http.PostForm(t.apiURL("setWebhook"), url.Values{"url": {webhook}}) + if !checkerr("SetWebhook/http.PostForm", err) { + defer resp.Body.Close() + var result tg.APIResponse + err = json.NewDecoder(resp.Body).Decode(&result) + if err != nil { + log.Println("[SetWebhook] Could not read reply: " + err.Error()) + return + } + if result.Ok { + log.Println("Webhook successfully set!") + } else { + log.Printf("[SetWebhook] Error setting webhook (errcode %d): %s\n", *(result.ErrCode), *(result.Description)) + panic(errors.New("Cannot set webhook")) + } + } +} + +// SendTextMessage sends an HTML-styled text message to a specified chat +func (t Telegram) SendTextMessage(data tg.ClientTextMessageData) { + postdata := url.Values{ + "chat_id": {strconv.FormatInt(data.ChatID, 10)}, + "text": {data.Text}, + "parse_mode": {"HTML"}, + } + if data.ReplyID != nil { + postdata["reply_to_message_id"] = []string{strconv.FormatInt(*(data.ReplyID), 10)} + } + + _, err := http.PostForm(t.apiURL("sendMessage"), postdata) + checkerr("SendTextMessage/http.PostForm", err) +} + +func (t Telegram) SendPhoto(data tg.ClientPhotoData) { + // Decode photo from b64 + photolen := base64.StdEncoding.DecodedLen(len(data.Bytes)) + photobytes := make([]byte, photolen) + decoded, err := base64.StdEncoding.Decode(photobytes, []byte(data.Bytes)) + if checkerr("SendPhoto/base64.Decode", err) { + return + } + + // Write file into multipart buffer + body := new(bytes.Buffer) + writer := multipart.NewWriter(body) + part, err := writer.CreateFormFile("photo", data.Filename) + if checkerr("SendPhoto/multipart.CreateFormFile", err) { + return + } + part.Write(photobytes[0:decoded]) + + // Write other fields + writer.WriteField("chat_id", strconv.FormatInt(data.ChatID, 10)) + + if data.ReplyID != nil { + writer.WriteField("reply_to_message_id", strconv.FormatInt(*data.ReplyID, 10)) + } + + if data.Caption != "" { + writer.WriteField("caption", data.Caption) + } + + err = writer.Close() + if checkerr("SendPhoto/writer.Close", err) { + return + } + + // Create HTTP client and execute request + client := &http.Client{} + req, err := http.NewRequest("POST", t.apiURL("sendPhoto"), body) + if checkerr("SendPhoto/http.NewRequest", err) { + return + } + + req.Header.Add("Content-Type", writer.FormDataContentType()) + + _, err = client.Do(req) + checkerr("SendPhoto/http.Do", err) +} + +func (t Telegram) ForwardMessage(data tg.ClientForwardMessageData) { + postdata := url.Values{ + "chat_id": {strconv.FormatInt(data.ChatID, 10)}, + "from_chat_id": {strconv.FormatInt(data.FromChatID, 10)}, + "message_id": {strconv.FormatInt(data.MessageID, 10)}, + } + + _, err := http.PostForm(t.apiURL("forwardMessage"), postdata) + checkerr("ForwardMessage/http.PostForm", err) +} + +func (t Telegram) SendChatAction(data tg.ClientChatActionData) { + postdata := url.Values{ + "chat_id": {strconv.FormatInt(data.ChatID, 10)}, + "action": {string(data.Action)}, + } + + _, err := http.PostForm(t.apiURL("sendChatAction"), postdata) + checkerr("SendChatAction/http.PostForm", err) +} + +// GetFile sends a "getFile" API call to Telegram's servers and fetches the file +// specified afterward. The file will be then send back to the client that requested it +// with the specified callback id. +func (t Telegram) GetFile(data tg.FileRequestData, client net.Conn, callback int) { + fail := func(msg string) { + errmsg, _ := json.Marshal(tg.BrokerUpdate{ + Type: tg.BError, + Error: &msg, + Callback: &callback, + }) + fmt.Fprintln(client, string(errmsg)) + } + + postdata := url.Values{ + "file_id": {data.FileID}, + } + resp, err := http.PostForm(t.apiURL("getFile"), postdata) + if checkerr("GetFile/post", err) { + fail("Server didn't like my request") + return + } + defer resp.Body.Close() + + var filespecs = struct { + Ok bool `json:"ok"` + Result *tg.APIFile `json:"result,omitempty"` + }{} + err = json.NewDecoder(resp.Body).Decode(&filespecs) + if checkerr("GetFile/json.Decode", err) { + fail("Server sent garbage (or error)") + return + } + if filespecs.Result == nil { + fail("Server didn't send a file info, does the file exist?") + return + } + result := *filespecs.Result + + path := APIEndpoint + "file/bot" + t.Token + "/" + *result.Path + fileresp, err := http.Get(path) + if checkerr("GetFile/get", err) { + fail("Could not retrieve file from Telegram's servers") + return + } + defer fileresp.Body.Close() + + rawdata, err := ioutil.ReadAll(fileresp.Body) + if checkerr("GetFile/ioutil.ReadAll", err) { + fail("Could not read file data") + return + } + + rawlen := len(rawdata) + if rawlen != *result.Size { + // ??? + log.Printf("[GetFile] WARN ?? Downloaded file does not match provided filesize: %d != %d\n", rawlen, *result.Size) + } + b64data := base64.StdEncoding.EncodeToString(rawdata) + + clientmsg, err := json.Marshal(tg.BrokerUpdate{ + Type: tg.BFile, + Bytes: &b64data, + Callback: &callback, + }) + if checkerr("GetFile/json.Marshal", err) { + fail("Could not serialize reply JSON") + return + } + + fmt.Fprintln(client, string(clientmsg)) +} + +func (t Telegram) apiURL(method string) string { + return APIEndpoint + "bot" + t.Token + "/" + method +} + +func checkerr(method string, err error) bool { + if err != nil { + log.Printf("[%s] Error: %s\n", method, err.Error()) + return true + } + return false +} diff --git a/cmd/tg-broker/webhook.go b/cmd/tg-broker/webhook.go new file mode 100644 index 0000000..8e999d1 --- /dev/null +++ b/cmd/tg-broker/webhook.go @@ -0,0 +1,33 @@ +package main + +import ( + "encoding/json" + "log" + "net/http" + + "github.com/hamcha/clessy/tg" +) + +func webhook(rw http.ResponseWriter, req *http.Request) { + defer req.Body.Close() + + // Re-encode request to ensure conformity + var update tg.APIUpdate + err := json.NewDecoder(req.Body).Decode(&update) + if err != nil { + log.Println("[webhook] Received incorrect request: " + err.Error()) + return + } + + data, err := json.Marshal(tg.BrokerUpdate{ + Type: tg.BMessage, + Message: &(update.Message), + Callback: nil, + }) + if err != nil { + log.Println("[webhook] Cannot re-encode json (??) : " + err.Error()) + return + } + + broadcast(string(data)) +} diff --git a/command.go b/command.go new file mode 100644 index 0000000..021108c --- /dev/null +++ b/command.go @@ -0,0 +1,103 @@ +package tg + +// BrokerUpdateType distinguishes update types coming from the broker +type BrokerUpdateType string + +const ( + // BMessage is a message update (mostly webhook updates) + BMessage BrokerUpdateType = "message" + + // BFile is a file retrieval response update + BFile BrokerUpdateType = "file" + + // BError is an error the broker occurred while fulfilling a request + BError BrokerUpdateType = "error" +) + +// BrokerUpdate is what is sent by the broker as update +type BrokerUpdate struct { + Type BrokerUpdateType + Callback *int `json:",omitempty"` + Error *string `json:",omitempty"` + Message *APIMessage `json:",omitempty"` + Bytes *string `json:",omitempty"` +} + +// ClientCommandType distinguishes requests sent by clients to the broker +type ClientCommandType string + +const ( + // CmdSendTextMessage requests the broker to send a text message to a chat + CmdSendTextMessage ClientCommandType = "sendText" + + // CmdSendPhoto requests the broker to send a photo to a chat + CmdSendPhoto ClientCommandType = "sendPhoto" + + // CmdForwardMessage requests the broker to forward a message between chats + CmdForwardMessage ClientCommandType = "forwardMessage" + + // CmdGetFile requests the broker to get a file from Telegram + CmdGetFile ClientCommandType = "getFile" + + // CmdSendChatAction requests the broker to set a chat action (typing, etc.) + CmdSendChatAction ClientCommandType = "sendChatAction" +) + +// ClientTextMessageData is the required data for a CmdSendTextMessage request +type ClientTextMessageData struct { + ChatID int64 + Text string + ReplyID *int64 `json:",omitempty"` +} + +// ClientPhotoData is the required data for a CmdSendPhoto request +type ClientPhotoData struct { + ChatID int64 + Bytes string + Filename string + Caption string `json:",omitempty"` + ReplyID *int64 `json:",omitempty"` +} + +// ClientForwardMessageData is the required data for a CmdForwardMessage request +type ClientForwardMessageData struct { + ChatID int64 + FromChatID int64 + MessageID int64 +} + +// ClientChatActionData is the required data for a CmdSendChatAction request +type ClientChatActionData struct { + ChatID int64 + Action ChatAction +} + +// ChatAction is the action name for CmdSendChatAction requests +type ChatAction string + +const ( + ActionTyping ChatAction = "typing" + ActionUploadingPhoto ChatAction = "upload_photo" + ActionRecordingVideo ChatAction = "record_video" + ActionUploadingVideo ChatAction = "upload_video" + ActionRecordingAudio ChatAction = "record_audio" + ActionUploadingAudio ChatAction = "upload_audio" + ActionUploadingDocument ChatAction = "upload_document" + ActionFindingLocation ChatAction = "find_location" +) + +// FileRequestData is the required data for a CmdGetFile request +type FileRequestData struct { + FileID string +} + +// ClientCommand is a request sent by clients to the broker +type ClientCommand struct { + Type ClientCommandType + TextMessageData *ClientTextMessageData `json:",omitempty"` + PhotoData *ClientPhotoData `json:",omitempty"` + ForwardMessageData *ClientForwardMessageData `json:",omitempty"` + ChatActionData *ClientChatActionData `json:",omitempty"` + FileRequestData *FileRequestData `json:",omitempty"` + Callback *int `json:",omitempty"` +}