This commit is contained in:
mappu 2018-06-03 18:24:08 +12:00
parent 6a7a7a51f6
commit 9bfdc42187
4 changed files with 241 additions and 56 deletions

View File

@ -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
}

View File

@ -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 := "<b>&lt;" + html.EscapeString(msg.evt.Nick) + "&gt;</b> " + 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)

View File

@ -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)

View File

@ -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