BastliBridge

A bot framework bridgin multiple IM protocols, and mail
git clone git://xatko.vsos.ethz.ch/BastliBridge.git
Log | Files | Refs | Submodules

commit 0470aa8af0d96b1960cddad2c6830d97b2bd4464
Author: Dominik Schmidt <das1993@hotmail.com>
Date:   Wed, 13 Sep 2017 17:37:55 +0200

Initial commit

Diffstat:
.gitmodules | 3+++
Dirk | 1+
Makefile | 29+++++++++++++++++++++++++++++
README.md | 20++++++++++++++++++++
src/telegram.d | 651+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
5 files changed, 704 insertions(+), 0 deletions(-)

diff --git a/.gitmodules b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "Dirk"] + path = Dirk + url = https://github.com/JakobOvrum/Dirk.git diff --git a/Dirk b/Dirk @@ -0,0 +1 @@ +Subproject commit e09e8b4456ab186cf0d425e233f2d37e2669a59c diff --git a/Makefile b/Makefile @@ -0,0 +1,29 @@ +DMD ?= ldmd2 +DEBUG ?= -g +RELEASE ?= -release -O +DFLAGS ?= +INCLUDES = Dirk/source/ +SRC := src/telegram.d $(wildcard Dirk/source/*/*.d) Dirk/libev/deimos/ev.d +TARGET = bastlibridge +LIBS = ev + +_DFLAGS := $(addprefix -I, $(INCLUDES)) $(addprefix -L-l, $(LIBS)) $(DFLAGS) + +.PHONY: all shared static clean distclean + +all: $(TARGET)-debug + +$(TARGET): $(patsubst %.d, %.o, $(SRC)) + $(DMD) $^ $(_DFLAGS) $(RELEASE) -of$@ +$(TARGET)-debug: $(patsubst %.d, %-debug.o, $(SRC)) + $(DMD) $^ $(_DFLAGS) $(DEBUG) -of$@ + +%.o:%.d + $(DMD) $(_DFLAGS) $(RELEASE) -c -of$@ $< +%-debug.o:%.d + $(DMD) $(_DFLAGS) $(DEBUG) -c -of$@ $< + +clean: + rm -f $(patsubst %.d, %.o, $(SRC)) $(patsubst %.d,%-debug.o,$(SRC)) +distclean: clean + rm -f $(TARGET){,-debug} diff --git a/README.md b/README.md @@ -0,0 +1,20 @@ +Bastli IRC Bridge +================= + +This Bot connects to the Telegram Bot API, as well as an IRC server, and bridges between them. + +Building +======= + + git submodule update --init + make + +Running +======= + +Depending on whether you built the debug-binary, you run + + ./bastlibridge-debug <Telegram API-Key> +or + + ./bastlibridge <Telegram API-Key> diff --git a/src/telegram.d b/src/telegram.d @@ -0,0 +1,651 @@ +import std.stdio; +import std.socket; +import std.range; +import std.algorithm; +import std.format; +import std.conv; +import std.experimental.logger; +import std.traits; +import ssl.socket; +import irc.client; +import std.file; +import std.typecons; +import std.string; +import std.json; +import std.exception; +import deimos.ev; + +class TelegramErrnoException : ErrnoException{ + this(string msg, string file=__FILE__, size_t line=__LINE__){ + super(msg,file,line); + } +} +class TelegramException : Exception{ + this(string msg, string file=__FILE__, size_t line=__LINE__, Throwable next=null){ + super(msg,file,line,next); + } +} + +class TelegramError : Error{ + this(string msg, string file=__FILE__, size_t line=__LINE__, Throwable next=null){ + super(msg,file,line,next); + } +} + + +struct Telegram{ + string token=""; + enum ApiAddr="api.telegram.org"; + enum ApiPort=443; + + long lastUpdate=-1; + + package SslSocket sock; + package SslSocket sock2; + + private Address ApiAddrObj; + + @disable this(); + this(string token){ + this.token=token; + } + + void connect(){ + openSocket(sock); + openSocket(sock2); + } + + void getApiAddress(){ + ApiAddrObj=getAddress(ApiAddr,ApiPort).front; + } + + void openSocket(T)(out T sock){ + if(!ApiAddrObj){ + getApiAddress(); + } + trace("Opening socket to Telegram on "~ApiAddrObj.to!string); + auto af=ApiAddrObj.addressFamily; + sock=new T(af); + sock.connect(ApiAddrObj); + scope(failure){ + sock.close(); + } + testSocket(sock); + } + + void testSocket(T)(T sock){ + //Try whether the socket works + trace("Trying to get the Botname via newly opened socket "~sock.handle.to!string); + botName=getBotName(sock); + } + + + void disconnect(){ + sock.shutdown(SocketShutdown.BOTH); + sock.close(); + delete sock; + sock2.shutdown(SocketShutdown.BOTH); + sock2.close(); + delete sock2; + } + + auto getURI(in char[] method){ + return chain("/bot",token,"/",method); + } + + void http(in char[] method){ + http(method, sock); + } + void http(in char[] method, Socket sock) + in{ + assert(sock !is null); + assert(sock.isAlive); + } + body{ + import std.conv:to; + string req="GET "~getURI(method).to!string~" HTTP/1.1\r\n" + ~"Host: "~ApiAddr~"\r\n" + ~"Connection: keep-alive\r\n"; + req~="\r\n\r\n"; + sock.send(req); + } + + void data(in char[] method, in char[] data, in char[] datatype){ + return this.data(method,data,datatype,sock); + } + + void data(in char[] method, in char[] data, in char[] datatype, Socket sock) + in{ + assert(sock !is null); + assert(sock.isAlive); + } + body{ + import std.conv:to; + string req="GET "~getURI(method).to!string~" HTTP/1.1\r\n" + ~"Host: "~ApiAddr~"\r\n" + ~"Connection: keep-alive\r\n"; + req~="Content-Length: "~data.length.to!string~"\r\n" + ~"Content-Type: "~datatype; + req~="\r\n\r\n"~data; + trace(req); + sock.send(req); + } + + void post(in char[] method, in char[] data){ + post(method,data,sock); + } + void post(in char[] method, in char[] data, Socket sock){ + return this.data(method, data, "application/x-www-form-urlencoded",sock); + } + void json(in char[] method, in char[] data){ + json(method,data,sock); + } + void json(in char[] method, in char[] data, Socket sock){ + return this.data(method, data, "application/json",sock); + } + + /** + * Receives all available data from the socket. + * The returned buffer gets reused, if you want it immutable, + * use .idup on it. + */ + const(char)[] receive(){ + return receive(sock); + } + const(char)[] receive(Socket sock) + in + { + assert(sock !is null); + assert(sock.isAlive); + } + body{ + static immutable size_t chunk_size=1024; + static ubyte[] buffer=new ubyte[chunk_size]; //This initialization is static, i.e. only done once + ubyte[] buf=buffer; + size_t pos=0,res=0; + while(true){ + res=sock.receive(buf); + if(res==0){ + info("Socket ", sock.handle, " died"); + sock.close(); + return []; + } + else if(res==Socket.ERROR){ + throw new TelegramErrnoException("Error reading from socket"); + } + pos+=res; + if(pos<buffer.length) + break; + if(pos>=buffer.length){ + buffer.length+=chunk_size; + } + buf=buffer[pos..$]; + } + return cast(const(char)[])buffer[0..pos]; + } + + Nullable!(JSONValue) response(Socket sock){ + auto buf=receive(sock); + if(buf.length==0){ + //throw new TelegramError("Socket closed"); + warning("Telegram closed _another_ socket :/ ",sock.handle); + reconnectSocket(sock); + return Nullable!JSONValue.init; + } + HTTP h=HTTP.parse(buf); + h.checkCode(); + return Nullable!JSONValue(h.getJSON()); + } + + void reconnectSocket(Socket sock){ + sock.shutdown(SocketShutdown.BOTH); + sock.close(); + //dreckige hackerei + void[] mem=(cast(void*)sock)[0..__traits(classInstanceSize, SslSocket)]; + emplace!SslSocket(mem,ApiAddrObj.addressFamily); + sock.connect(ApiAddrObj); + } + + struct HTTP{ + string ver; + ushort code; + string code_name; + const(char)[] content; + + static HTTP parse(const(char)[] buf) + in{ + assert(buf.length>0); + } + body{ + HTTP res; + trace("Parseing "~buf); + auto lines=buf.splitter("\r\n"); + auto status=lines.front; + auto split=status.splitter(" "); + + res.ver=split.front.idup; + split.popFront(); + res.code=split.front.to!ushort; + split.popFront(); + res.code_name=split.joiner.to!string; + + res.content=buf.findSplit("\r\n\r\n")[2]; + trace("Parsed to "~res.to!string); + return res; + } + + void checkCode(){ + if(code!=200){ + throw new TelegramException(format!"Server returned %d\n%s"(code, content)); + } + } + + auto getJSON(){ + import std.json; + + auto j=parseJSON(content); + if(j["ok"].type != JSON_TYPE.TRUE){ + throw new TelegramException("API request failed: "~j["description"].str); + } + return j["result"]; + } + } + + void triggerUpdates(uint timeout=600){ + string params=format!"offset=%d&timeout=%d"(lastUpdate+1,timeout); + post("getUpdates",params); + } + + void delegate(JSONValue jv)[] onMessage; + + bool read(){ + auto buf=cast(const(char)[])receive(); + if(buf.length==0){ + return true; + } + + HTTP response=HTTP.parse(buf); + response.checkCode(); + + auto updates=response.getJSON(); + foreach(size_t i, update; updates){ + lastUpdate=max(lastUpdate, update["update_id"].integer); + writeln(lastUpdate); + if("message" in update){ + onMessage.each!(a=>a(update["message"])); + } + } + + return false; + } + + void send(in long chatid, in char[] buf){ + trace("Sending "~buf~" to "~chatid.to!string); + JSONValue j; + j.object=null; + j["chat_id"] = JSONValue(chatid); + j["text"] = JSONValue(buf); + trace("Sending telegram message "~j.toPrettyString); + json("sendMessage", j.toString,sock2); + auto res=response(sock2); + if(res.isNull){ + send(chatid,buf); + } + } + + JSONValue getChat(T)(in T chatid) if(isNarrowString!T || is(T==long)){ + JSONValue j; + j.object=null; + j["chat_id"]=JSONValue(chatid); + string data=j.toString; + json("getChat", data, sock2); + auto res=response(sock2); + if(res.isNull){ + return getChat(chatid); + } + return res.get; + } + long getChatId(in char[] chatid){ + return getChat(chatid)["id"].integer; + } + string getChatName(long chatid){ + auto j=getChat(chatid); + if("title" in j){ + //We are in a group + return j["title"].str; + } + //We are in a query + return j["username"].str; + } + + string botName; + + string getBotName(){ + return getBotName(sock); + } + string getBotName(Socket sock){ + http("getMe", sock); + string _botName=response(sock)["username"].str; + return _botName; + } +} + +struct LookupTable{ + private long[][string] _irc; + private string[][long] _telegram; + void connect(string irc, long telegram){ + _irc[irc]~=telegram; + _telegram[telegram]~=irc; + } + void disconnect(string irc, long telegram){ + auto i=irc in _irc; + *i=remove!(a=>a==telegram)(*i); + auto t=telegram in _telegram; + *t=remove!(a=>a==irc)(*t); + } + + long[] irc(in char[] irc){ + return _irc.get(cast(string)irc,[]); + } + + string[] telegram(in long telegram){ + return _telegram.get(telegram,[]); + } + auto ircChannels(){ + return _irc.byKey; + } + auto telegramChannels(){ + return _telegram.byKey; + } + + auto links(){ + return _irc.byPair.map!(a=>a[1].map!(b=>tuple(a[0],b))).joiner; + } +} + + +struct Watcher{ + ev_io io; + void delegate(int revents) cb; + extern(C) static void callback(ev_loop_t* loop, ev_io *io, int revents){ + auto watcher=cast(Watcher*) io; + watcher.cb(revents); + } + this(Socket sock, typeof(this.cb) cb){ + this(sock.handle,cb); + } + this(int fd, typeof(this.cb) cb){ + this.cb=cb; + ev_io_init(&io, &callback, fd, EV_READ); + } +} + +struct Bot{ + Address ircAddress; + Socket ircSocket; + IrcClient ircClient; + Telegram telegram; + LookupTable lut; + Watcher w_irc,w_tele,w_stdin; + ev_loop_t *eventloop; + + string[long] telegram_channels; + + this(string tgramApiKey){ + telegram=Telegram("253031348:AAGn4dHCPwFDSN4Bmt4ZWc3bIAmARmzsf9k"); + } + + void initialize(){ + ircAddress=getAddress("irc.freenode.net",6697)[0]; + auto af=ircAddress.addressFamily; + ircSocket=new SslSocket(af); + ircClient=new IrcClient(ircSocket); + + //lut.connect("#bastli_wasserstoffreakteur", -8554836); + + + ircClient.realName="Bastli"; + ircClient.userName="bastli"; + ircClient.nickName="bastli"; + ircClient.onConnect~=((){ + info("Connected to IRC on ",ircClient.serverAddress); + foreach(k;lut.ircChannels){ + ircClient.join(k); + } + }); + ircClient.onMessage~=(u,tt,m){ + trace("IRC received message from "~u.nickName~" on "~tt~": "~m); + string msg=cast(string)m; + if(msg.skipOver("!bastli ")){ + trace("Message is a command"); + try{ + auto split=msg.findSplit(" "); + switch(split[0]){ + case "links": + ircClient.send(tt,lut.irc(tt).map!(a=>telegram.getChatName(a)~"("~a.to!string~")").join(", ")); + break; + case "link": + //link(tt.idup, split[2].to!long); + break; + case "unlink": + unlink(tt.idup, split[2].to!long); + break; + case "list_telegram": + ircClient.send(tt, telegram_channels.byPair.map!(a=>a[1]~"("~a[0].to!string~")").join(", ")); + break; + default: + ircClient.send(tt, "Unknown command"); + break; + } + } + catch(Exception e){ + ircClient.send(tt, "Error while executing command: "~e.msg); + warning(e.to!string); + } + } + else{ + foreach(chatid; lut.irc(tt)){ + telegram.send(chatid, "< "~u.nickName~"> "~m); + } + } + }; + + + telegram.onMessage~=(j){ + trace("Received Message "~j.toPrettyString()); + auto msg=j["text"].str; + auto chatid=j["chat"]["id"].integer; + if(chatid !in telegram_channels){ + if("title" in j["chat"]){ + telegram_channels[chatid]=j["chat"]["title"].str; + } + else{ + telegram_channels[chatid]=j["chat"]["username"].str; + } + } + if(msg.skipOver("/")){ + trace("Bot command received"); + try{ + auto split=msg.findSplit(" "); + switch(split[0].findSplit("@")[0]){ + case "link": + link(split[2], chatid); + ircClient.notice(split[2], "Linked with "~chatid.to!string); + break; + case "links": + telegram.send(chatid, lut.telegram(chatid).join(", ")); + break; + case "unlink": + unlink(split[2], chatid); + ircClient.notice(split[2], "Unlinked with "~chatid.to!string); + break; + default: + //telegram.send(chatid, split[0]~": Unknown command"); + break; + } + } + catch(Exception e){ + telegram.send(chatid, "Error executing command: "~e.msg); + warning(e.to!string); + } + } + else{ + auto irc_chans=lut.telegram(chatid); + if(irc_chans.length==0){ + warning("Input on not-connected telegram-channel "~j["chat"].toPrettyString); + } + foreach(chan; irc_chans){ + ircClient.send(chan, "< "~j["from"]["username"].str~"> "~msg); + } + } + }; + } + + void unlink(string irc, long telegram){ + info("Unlinking ", irc, " from ", telegram); + lut.disconnect(irc, telegram); + if(ircClient.connected()){ + if(lut.irc(irc).length==0){ + ircClient.part(irc); + } + } + } + + void link(string irc, long telegram){ + info("Linking ", irc, " with ", telegram); + lut.connect(irc, telegram); + if(ircClient.connected()){ + ircClient.join(irc); + } + } + + void connect(){ + info("Connecting to Telegram"); + telegram.connect(); + //ircClient.connect(new InternetAddress("127.0.0.1",6667)); + info("Connecting to IRC"); + ircClient.connect(ircAddress); + } + + void setupEventloop(){ + eventloop=ev_default_loop(0); + + w_irc=Watcher(ircSocket,(revents){ + trace("IRC Update"); + if(ircClient.read()){ + ev_io_stop(eventloop, &w_irc.io); + } + }); + + w_tele=Watcher(telegram.sock,(revents){ + trace("Telegram Update"); + Exception e; + bool res; + try res=telegram.read(); + catch(TelegramException te) error(te); + if(res){ + ev_io_stop(eventloop, &w_tele.io); + } + else{ + telegram.triggerUpdates(); + } + }); + + w_stdin=Watcher(stdin.fileno, (revents){ + auto l=stdin.readln.chomp; + auto split=l.findSplit(" "); + switch(split[0]){ + case "quit": + quit(); + break; + case "link": + auto split2=split[2].findSplit(" "); + link(split2[0], split2[2].to!long); + break; + case "links": + foreach(i,tt; lut._irc.byPair){ + foreach(t; tt){ + writeln(i, " <-> ", t); + } + } + break; + case "unlink": + break; + default: + writeln(l, ": unknown command"); + } + + }); + } + + void quit(){ + info("Quitting the bot"); + ircClient.quit("Exiting gracefully"); + disconnect(); + stop(); + } + + void start(){ + info("Starting the event-listeners"); + telegram.triggerUpdates(); + ev_io_start(eventloop, &w_irc.io); + ev_io_start(eventloop, &w_tele.io); + ev_io_start(eventloop, &w_stdin.io); + } + + void stop(){ + trace("Stopping the Eventloop"); + ev_io_stop(eventloop, &w_tele.io); + ev_io_stop(eventloop, &w_irc.io); + ev_io_stop(eventloop, &w_stdin.io); + ev_break(eventloop, EVBREAK_ALL); + } + + void run(){ + info("Running the event-loop"); + ev_run(eventloop,0); + } + + void disconnect(){ + telegram.disconnect(); + } + + void save(File f){ + foreach(link; lut.links){ + f.writeln(link[0], " ", link[1].to!string); + } + } + void load(File f){ + foreach(l; f.byLine){ + auto split=l.findSplit(" "); + link(split[0].idup, split[2].to!long); + } + } +} + + +int main(string[] args){ + Bot b=Bot(args[1]); + b.initialize(); + if(exists("savefile")){ + auto f=File("savefile", "r"); + b.load(f); + f.close(); + } + b.connect(); + scope(failure){ + b.quit(); + } + b.setupEventloop(); + b.start(); + b.run(); + scope(exit){ + auto f=File("savefile", "w+"); + b.save(f); + f.close(); + } + + return 0; + +} +