diff --git a/NTFConfig.go b/NTFConfig.go index 41ba422..c0237eb 100644 --- a/NTFConfig.go +++ b/NTFConfig.go @@ -10,6 +10,7 @@ import ( type NTFConfig struct { HubAddr string HubDescription string + HubSecNick string // Nickname of Hub-Security to exclude (e.g. "PtokaX") BotAPIKey string GroupChatID int64 @@ -18,6 +19,9 @@ type NTFConfig struct { // Map of telegram users known to be in the group chat ==> telegram displayname GroupChatMembers map[int64]string + + // Map of telegram users to their direct conversation ID with the bot + DirectMessageChats map[int64]int64 } func LoadConfig(configFile string) (NTFConfig, error) { @@ -32,6 +36,16 @@ func LoadConfig(configFile string) (NTFConfig, error) { return NTFConfig{}, err } + if ret.KnownUsers == nil { + ret.KnownUsers = make(map[int64]string) + } + if ret.GroupChatMembers == nil { + ret.GroupChatMembers = make(map[int64]string) + } + if ret.DirectMessageChats == nil { + ret.DirectMessageChats = make(map[int64]int64) + } + return ret, nil } diff --git a/NTFServer.go b/NTFServer.go index 92519fd..939689f 100644 --- a/NTFServer.go +++ b/NTFServer.go @@ -3,6 +3,7 @@ package main import ( "errors" "fmt" + "html" "log" "strings" "time" @@ -32,6 +33,7 @@ func NewNTFServer(configFile string) (*NTFServer, error) { ret := NTFServer{ configFile: configFile, hubMessages: make(chan upstreamMessage, 0), + conns: make(map[string]*libnmdc.HubConnection), } // Config @@ -105,6 +107,10 @@ func NewNTFServer(configFile string) (*NTFServer, error) { // An actual NMDC connection will occur once the user joins for the first time. func (this *NTFServer) registerUser(telegramUserId int64, hubUsername string) error { if existingHubNick, ok := this.config.KnownUsers[telegramUserId]; ok { + if existingHubNick == hubUsername { + return nil + } + return fmt.Errorf("Telegram account is already registered with hub nick '%s'", existingHubNick) } @@ -140,6 +146,8 @@ func (this *NTFServer) LaunchUpstreamWorker(telegramUserId int64, hubUsername st return fmt.Errorf("Duplicate hub connection for user '%s', abandoning", hubUsername) } + log.Printf("Connecting to hub '%s' as user '%s'...", this.config.HubAddr, hubUsername) + // We want to use the async NMDC connection, but at the same time, provide // extra information in the channel. Wrap the upstream NMDC channel with one // of our own @@ -154,14 +162,14 @@ func (this *NTFServer) LaunchUpstreamWorker(telegramUserId int64, hubUsername st } }() + hubUser := libnmdc.NewUserInfo(hubUsername) + hubUser.ClientTag = AppName + hubUser.ClientVersion = AppVersion + conn := libnmdc.ConnectAsync( &libnmdc.HubConnectionOptions{ Address: libnmdc.HubAddress(this.config.HubAddr), - Self: &libnmdc.UserInfo{ - Nick: hubUsername, - ClientTag: AppName, - ClientVersion: AppVersion, - }, + Self: hubUser, }, upstreamChan, ) @@ -205,6 +213,36 @@ func (this *NTFServer) Run() error { } func (this *NTFServer) HandleHubMessage(msg upstreamMessage) { + switch msg.evt.EventType { + + case libnmdc.EVENT_SYSTEM_MESSAGE_FROM_CONN, libnmdc.EVENT_CONNECTION_STATE_CHANGED: + log.Printf("Hub(%s): * %s", msg.hubNick, msg.evt.Message) + + case libnmdc.EVENT_PRIVATE: + err := this.DirectMessageTelegramUser(msg.telegramUserId, fmt.Sprintf("PM from user '%s': %s", msg.evt.Nick, msg.evt.Message)) + if err != nil { + log.Printf("Delivering PM to telegram user: %s", err.Error()) + } + + case libnmdc.EVENT_PUBLIC: + if msg.evt.Nick == this.config.HubSecNick { + return // ignore + } + + // FIXME coalesce from multiple connections + htmlMsg := "<" + html.EscapeString(msg.evt.Nick) + "> " + html.EscapeString(msg.evt.Message) + err := this.GroupChatSayHTML(htmlMsg) + if err != nil { + log.Printf("Delivering public message to group chat: %s", err.Error()) + } + + case libnmdc.EVENT_USER_JOINED, libnmdc.EVENT_USER_PART, libnmdc.EVENT_USER_UPDATED_INFO, libnmdc.EVENT_USERCOMMAND, libnmdc.EVENT_DEBUG_MESSAGE, libnmdc.EVENT_HUBNAME_CHANGED: + // ignore + + default: + log.Printf("Hub(%s): Unhandled(%d): %s", msg.hubNick, msg.evt.EventType, msg.evt.Message) + + } log.Printf("Hub: %#v", msg) } @@ -237,27 +275,60 @@ func (this *NTFServer) HandleMessage(update telegram.Update) { log.Printf("[%s] %s", update.Message.From.UserName, update.Message.Text) } +// kickAndDrop deregisters an account and kicks them from the group chat +func (this *NTFServer) kickAndDrop(telegramUserId int64) { + + // Hubnick + if hubNick, ok := this.config.KnownUsers[telegramUserId]; ok { + + // Close upstream connection (if any) + if conn, ok := this.conns[hubNick]; ok { + delete(this.conns, hubNick) + + log.Printf("Disconnecting '%s' from hub", hubNick) + conn.Disconnect() + } + + // Deregister from known users + delete(this.config.KnownUsers, telegramUserId) + err := this.config.Save(this.configFile) + if err != nil { + log.Printf("Couldn't save changes when deregistering user: %s", err.Error()) + } + } + + // Kick from telegram groupchat (if logged in) + _, err := this.bot.KickChatMember(telegram.KickChatMemberConfig{ + ChatMemberConfig: telegram.ChatMemberConfig{ + ChatID: this.config.GroupChatID, + UserID: int(telegramUserId), + }, + UntilDate: time.Now().Add(24 * time.Hour).Unix(), + }) + if err != nil { + log.Printf("Couldn't kick user from telegram group: %s", err.Error()) + } +} + +// HandleTelegramUserParted processes a user leaving the groupchat, without deregistering their account func (this *NTFServer) HandleTelegramUserParted(telegramUserId int64, update telegram.Update) error { delete(this.config.GroupChatMembers, telegramUserId) return this.config.Save(this.configFile) } -func (this *NTFServer) HandleTelegramUserJoined(telegramUserId int64, update telegram.Update) error { +func (this *NTFServer) HandleTelegramUserJoined(telegramUserId int64, telegramDisplayName string, update telegram.Update) error { // If known, spawn the upstream connection; if unknown, kick them hubNick, ok := this.config.KnownUsers[telegramUserId] if !ok { - _, err := this.bot.KickChatMember(telegram.KickChatMemberConfig{ - ChatMemberConfig: telegram.ChatMemberConfig{ - ChatID: update.Message.Chat.ID, - UserID: int(telegramUserId), - }, - UntilDate: time.Now().Add(24 * time.Hour).Unix(), - }) - if err != nil { - log.Printf("Couldn't kick unexpected chat member from group: %s", err.Error()) - } + log.Printf("Unexpected user '%s' (%d) joined the group chat, kicking...", telegramDisplayName, telegramUserId) + this.kickAndDrop(telegramUserId) + } - return nil + // Remember which users are currently joined + this.config.GroupChatMembers[telegramUserId] = telegramDisplayName + err := this.config.Save(this.configFile) + if err != nil { + return err } // Spawn the upstream connection for this user @@ -272,7 +343,7 @@ func (this *NTFServer) HandleGroupMessage(update telegram.Update) error { // Ensure that they have a valid user mapping // Create upstream NMDC connection for them for _, joinedUser := range *update.Message.NewChatMembers { - err := this.HandleTelegramUserJoined(int64(joinedUser.ID), update) + err := this.HandleTelegramUserJoined(int64(joinedUser.ID), joinedUser.String(), update) if err != nil { log.Printf("Handling user join: %s", err.Error()) } @@ -283,77 +354,154 @@ func (this *NTFServer) HandleGroupMessage(update telegram.Update) error { if update.Message.LeftChatMember != nil { // User parted // Close upstream NMDC connection for them + log.Printf("Telegram user '%s' (%d) leaving group chat", update.Message.LeftChatMember.String(), update.Message.LeftChatMember.ID) return this.HandleTelegramUserParted(int64(update.Message.LeftChatMember.ID), update) } - // Actual chat message - // Find the responsible user and send it to the upstream hub using their account - // TODO + if len(update.Message.Text) > 0 { + // Actual chat message + // Find the responsible user and send it to the upstream hub, using their account + // FIXME also add it to the coalesce buffer so that we don't replay it from someone else's NMDC connection + + userID := int64(update.Message.From.ID) + hubNick, ok := this.config.KnownUsers[userID] + if !ok { + return fmt.Errorf("Couldn't send public message for user '%d' unexpectedly missing hub nick!", userID) + } + + conn, ok := this.conns[hubNick] + if !ok { + return fmt.Errorf("Couldn't send public message for user '%d' (%s) unexpectedly missing upstream connection!", userID, hubNick) + } + + conn.SayPublic(update.Message.Text) + } + + // TODO probably a file/image upload??? + // TODO support "editing messages" by re-sending them with a ** suffix return nil } +func (this *NTFServer) ReplyTelegramUser(userID int64, str string, replyToMessageID int) error { + chatId, ok := this.config.DirectMessageChats[userID] + if !ok { + return fmt.Errorf("Can't send telegram message to user '%s': no DM chat known", userID) + } + + msg := telegram.NewMessage(chatId, str) + if replyToMessageID != 0 { + msg.ReplyToMessageID = replyToMessageID + } + _, err := this.bot.Send(msg) + return err +} + +func (this *NTFServer) DirectMessageTelegramUser(userID int64, str string) error { + return this.ReplyTelegramUser(userID, str, 0) +} + +func (this *NTFServer) GroupChatSayHTML(str string) error { + msg := telegram.NewMessage(this.config.GroupChatID, str) + msg.ParseMode = telegram.ModeHTML + _, err := this.bot.Send(msg) + return err +} + func (this *NTFServer) HandleDirectMessage(update telegram.Update) error { // Registration workflow - // Find out the current status for this chat ID userID := int64(update.Message.From.ID) - hubNick, isKnown := this.config.KnownUsers[userID] + + // Stash the telegram user ID against this direct-message chat ID so that + // we can always reply later on + chatID := update.Message.Chat.ID + if oldChatID, ok := this.config.DirectMessageChats[userID]; !ok || oldChatID != chatID { + this.config.DirectMessageChats[userID] = chatID + err := this.config.Save(this.configFile) + if err != nil { + log.Printf("Couldn't save chat ID %d for user %d", chatID, userID) + } + } respond := func(str string) error { - msg := telegram.NewMessage(update.Message.Chat.ID, str) - msg.ReplyToMessageID = update.Message.MessageID - _, err := this.bot.Send(msg) - return err + return this.ReplyTelegramUser(userID, str, update.Message.MessageID) } + // Find out the current status for this chat ID + hubNick, isKnown := this.config.KnownUsers[userID] + _, isInGroupChat := this.config.GroupChatMembers[userID] + // Handle the incoming request msg := update.Message.Text if strings.HasPrefix(msg, "/pm ") { - if !isKnown { + if !(isKnown && isInGroupChat) { return respond("Can't send a native PM until you've joined.") } - // TODO - return respond("Not yet implemented.") - - } else if strings.HasPrefix(msg, "/join ") { - if isKnown { - return respond("You've already joined as '" + hubNick + "'.") + conn, ok := this.conns[hubNick] + if !ok { + return respond("Can't send a native PM (No upstream hub connection)") } + parts := strings.SplitN(msg, " ", 3) + if len(parts) != 3 { + return respond("Expected format /pm [recipient] [message] - try again...") + } + + if !conn.UserExists(parts[1]) { + return respond(fmt.Sprintf("Can't PM offline user '%s'", parts[1])) + } + + conn.SayPrivate(parts[1], parts[2]) + + } else if strings.HasPrefix(msg, "/join ") { requestedHubNick := msg[6:] err := this.registerUser(userID, requestedHubNick) if err != nil { - log.Printf("Failed to join user: %s", err.Error()) - return respond(fmt.Sprintf("Couldn't allow join because: %s", err.Error())) + log.Printf("Failed to register user: %s", err.Error()) + return respond(fmt.Sprintf("Couldn't allow registration because: %s", err.Error())) } - return respond(fmt.Sprintf("Hi '%s'! You can join %s at %s", requestedHubNick, this.config.HubDescription, this.inviteLink)) + return respond(fmt.Sprintf("Hi '%s'! You are now registered, and can join %s at %s", requestedHubNick, this.config.HubDescription, this.inviteLink)) + + } else if strings.HasPrefix(msg, "/rejoin") { + if isKnown && !isInGroupChat { + return respond(fmt.Sprintf("Welcome back '%s'! You can join %s at %s", hubNick, this.config.HubDescription, this.inviteLink)) + + } else { + return respond("You are either already joined (try /quit first), or not yet registered (try /join first).") + } } else if strings.HasPrefix(msg, "/quit") { - // TODO - return respond("Not yet implemented.") + this.kickAndDrop(userID) + return respond("Disconnected. You can register again by typing /help .") - } else { // e.g. /setup or /help + } else { // e.g. /start or /help // Help helpMsg := `I am a bot that connects Telegram with ` + this.config.HubDescription + ".\n" if isKnown { - helpMsg += "You are currently connected as: '" + hubNick + "'\n" + helpMsg += "You are currently registered as: '" + hubNick + "'\n" } else { helpMsg += "You aren't connected yet.\n" } + if isInGroupChat { + helpMsg += "You are currently in the groupchat.\n" + } else { + helpMsg += "You haven't joined the groupchat.\n" + } helpMsg += ` Available commands: /setup - Welcome message -/join [hubnick] - Join ` + this.config.HubDescription + ` as user 'hubnick' +/join [hubnick] - Register as 'hubnick' and join ` + this.config.HubDescription + ` +/rejoin - Re-invite if you are already registered /pm [recipient] [message] - Send a native PM (if connected) -/quit - Leave the group chat +/quit - Unregister your nick and leave the group chat ` return respond(helpMsg) diff --git a/README.txt b/README.txt index 5065413..f38547c 100644 --- a/README.txt +++ b/README.txt @@ -1,20 +1,35 @@ -A bot to synchronise an NMDC hub with a Telegram supergroup. +A bot to synchronise chat between an NMDC/ADC hub and a Telegram supergroup. -## USAGE +Tags: NMDC +Written in Go -Create a new bot +## FEATURES + +- NMDC/ADC support +- Exclude messages from hub-security nicks +- Standalone binary + +## SETUP + +Create a new telegram bot - Use BotFather to create a new bot - - Use BotFather to change its privacy mode for group chats + - Use BotFather to disable its privacy mode for group chats -Create a group +Create a telegram group - Manually create a group chat and add the bot to it - Convert group chat to supergroup - Grant bot to be an administrator (including ability to add more administrators) - Settings > "Who can add members" > Only administrators - Create an invite link -Start nmdc-telegramfrontend +Handover to nmdc-telegramfrontend - Run this bot with no -GroupChatID, to learn the groupchat ID - Post a test message in the group chat, to discover the groupchat ID - - Leave the group chat + - Leave the group chat (long press on mobile, can't do it on desktop) - Run this bot with -GroupChatID for normal operation + +## USAGE + +Chat with the bot to enter/leave the synchronised channel. + +Sometimes the telegram invite links can take a few minutes to activate, especially if there has been unusual activity (e.g. frequent join/parts) diff --git a/TODO.txt b/TODO.txt index 86fb5f3..9fbdfb2 100644 --- a/TODO.txt +++ b/TODO.txt @@ -1,19 +1,26 @@ [X] Menu for direct messages [X] Commands for new user registration - [ ] If registered/online -- responding to PMs (always require prefix) + [X] If registered/online -- responding to PMs (always require prefix) [X] Open upstream connections [X] when registered users join the groupchat [X] open existing connections at app startup [ ] Handle upstream messages - [ ] Coalesce matches from multiple upstream connections? - [ ] When recieving a PM - send directly to telegram user account + [X] System messages + [X] Public messages + [ ] Coalesce matches from multiple upstream connections (timer?) + [X] Exclude messages from hub-security (custom nick) + [ ] Allow sending public messages to DC from inside the group chat + [ ] Nick translation + [X] Private messages + [X] Send directly to telegram user account (even if they're telegramuser-on-dc - telegramuser-on-dc PMs i guess) + [X] Allow replying to PMs with /pm [recipient] message -[ ] Graceful termination - [ ] Ability to close nmdc connections - [ ] Close nmdc connection && deregister user (with bot message) when user leaves groupchat +[X] Graceful termination + [X] Ability to close nmdc connections + [X] Close nmdc connection && deregister user (with bot message) when user leaves groupchat [X] Block unknown users from entering telegram channel @@ -21,5 +28,6 @@ [ ] Convert telegram files/images/videos to raw URLs for NMDC [ ] Bot API does allow downloading - but (?authenticated)? need to re-host (either internally or upload to contented server) [ ] Contented support - convert known URLs with image mime type to telegram images + [ ] Including private messages??? is that possible? [ ] Publish