├── LICENSE ├── cmd.sh ├── README.md └── main.go /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2016 Morten Linderud 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /cmd.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/bash 2 | 3 | 4 | # Watches over the directories and executes commands if it 5 | # finds anything. 6 | # 7 | # It is horrible, i know 8 | 9 | CMDMARK="." 10 | CMDDIR="./irc/cmd" 11 | 12 | 13 | TMPFILE=./irc/queries.tmp 14 | 15 | DATELASTCHECK=$(date -r $TMPFILE +"%Y%m%d%H%M%S") 16 | 17 | if [ ! -f $TMPFILE ]; then 18 | touch $TMPFILE 19 | fi 20 | 21 | readonly IRCPATH="./irc" 22 | for i in `find $IRCPATH -name 'out'` 23 | do 24 | grep -v '\-!\-' $i > /dev/null 2>&1 # if file doesnt just contain server stuff 25 | if [ $? -ne 1 ]; then 26 | tail -5 $i | while read line 27 | do 28 | timeFile=$(echo $line | cut -d" " -f-2 | xargs -i -0 date -d {} +"%Y%m%d%H%M%S") 29 | if [ $timeFile -ge $DATELASTCHECK ]; 30 | then 31 | outFile=$(dirname $i)/in 32 | cmdLine=$(echo $line|cut -d" " -f4-) 33 | if [[ $cmdLine == $CMDMARK* ]]; then 34 | cmdFile=$(echo $cmdLine | cut -d"." -f2- | cut -d" " -f1) 35 | cmdArgs=$(echo $cmdLine | cut -d" " -f2-) 36 | $CMDDIR/$cmdFile $cmdArgs > $outFile 37 | fi 38 | fi 39 | done 40 | fi 41 | done 42 | 43 | 44 | touch $TMPFILE 45 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | iii 2 | === 3 | 4 | iii - ii Improved 5 | 6 | 7 | suckless version of suckless ii, but with horriblehorrible Golang. 8 | http://tools.suckless.org/ii/ 9 | 10 | iii (and ii) is a filesystem-based IRC client, using files and FIFO pipes to 11 | communicate with the IRC server. It allows the creation of simple scripts and 12 | bots. 13 | 14 | The goal is to have 1:1 feature parity with ii, and some additional features: 15 | * TLS Support 16 | * Server reconnection & channel rejoin 17 | 18 | 19 | ``` 20 | → iii --help 21 | Usage of ./iii: 22 | -f string 23 | Specify a default real name (default "ii Improved") 24 | -i string 25 | Specify a path for the IRC connection (default "~/irc") 26 | -k string 27 | Specify a environment variable for your IRC password (default "IIPASS") 28 | -n string 29 | Specify a default nick (default "iii") 30 | -p string 31 | Server port (default 6667, SSL default 6697) 32 | -s string 33 | Specify server (default "irc.freenode.net") 34 | -tls 35 | Use TLS for the connection (default false) 36 | 37 | ``` 38 | 39 | ``` 40 | → tree irc 41 | irc 42 | └── irc.hackint.org 43 | ├── #buf 44 | │   ├── in 45 | │   └── out 46 | ├── foxboron 47 | │   ├── in 48 | │   └── out 49 | ├── in 50 | └── out 51 | 52 | 3 directories, 6 files 53 | ``` 54 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bufio" 5 | "crypto/tls" 6 | "flag" 7 | "fmt" 8 | "log" 9 | "net" 10 | "os" 11 | "os/user" 12 | "strings" 13 | "syscall" 14 | "time" 15 | ) 16 | 17 | type Msg struct { 18 | channel string 19 | file string 20 | msg string 21 | } 22 | 23 | type Server struct { 24 | server string 25 | conn net.Conn 26 | port string 27 | nick string 28 | realName string 29 | password string 30 | channels map[string]bool 31 | msgChan chan Msg 32 | serverChan chan string 33 | tls bool 34 | Dir string 35 | } 36 | 37 | type Parsed struct { 38 | nick string 39 | userinfo string 40 | event string 41 | channel string 42 | raw string 43 | args []string 44 | } 45 | 46 | // Thanks Twisted 47 | func parse(s string) Parsed { 48 | raw := s 49 | var prefix string 50 | var command string 51 | var args []string 52 | var trailing []string 53 | var nick string 54 | var userinfo string 55 | 56 | if string(s[0]) == ":" { 57 | ret := strings.SplitN(s[1:], " ", 2) 58 | prefix = ret[0] 59 | s = ret[1] 60 | } 61 | if strings.Index(s, " :") != -1 { 62 | ret := strings.SplitN(s, " :", 2) 63 | s = ret[0] 64 | trailing = ret[1:] 65 | 66 | args = strings.Split(s, " ") 67 | args = append(args, trailing...) 68 | } else { 69 | args = strings.Split(s, " ") 70 | } 71 | command = args[0] 72 | args = args[1:] 73 | 74 | prefixSplit := strings.Split(prefix, "!") 75 | if len(prefixSplit) == 1 { 76 | nick = "" 77 | userinfo = "" 78 | } else { 79 | nick = prefixSplit[0] 80 | userinfo = prefixSplit[1] 81 | } 82 | 83 | return Parsed{nick: nick, 84 | channel: args[0], 85 | userinfo: userinfo, 86 | event: command, 87 | raw: raw, 88 | args: args} 89 | } 90 | 91 | // Creates the fifo files and directories 92 | func createFiles(directory string) bool { 93 | if _, err := os.Stat(directory); err == nil { 94 | return false 95 | } 96 | err := os.MkdirAll(directory, 0744) 97 | if err != nil { 98 | log.Print("Tried making directory:", directory, err) 99 | } 100 | f, err := os.OpenFile(directory+"/out", os.O_CREATE, 0660) 101 | defer f.Close() 102 | if err != nil { 103 | log.Print("Tried opening out file for directory:", directory, err) 104 | } 105 | err = syscall.Mkfifo(directory+"/in", 0700) 106 | if err != nil { 107 | log.Print("Tried creating fifo file for directory:", directory, err) 108 | } 109 | return true 110 | } 111 | 112 | func (server *Server) writeOutLog(channel string, text Parsed) { 113 | 114 | msg := "" 115 | if text.event == "PRIVMSG" { 116 | msg = fmt.Sprintf("<%s> %s", text.nick, text.args[1]) 117 | } else if text.event == "JOIN" { 118 | msg = fmt.Sprintf("-!- %s(~%s) has joined %s", text.nick, text.userinfo, text.channel) 119 | } else if text.event == "PART" { 120 | msg = fmt.Sprintf("-!- %s(~%s) has left %s", text.nick, text.userinfo, text.channel) 121 | } else if text.event == "QUIT" { 122 | msg = fmt.Sprintf("-!- %s(~%s) has quit", text.nick, text.userinfo) 123 | } else if text.event == "MODE" { 124 | msg = fmt.Sprintf("-!- %s changed mode/%s -> %s", text.nick, text.channel, text.args[1]) 125 | } else if text.event == "NOTICE" { 126 | msg = fmt.Sprintf("-!- NOTICE %s", text.args[1]) 127 | } else if text.event == "KICK" { 128 | msg = fmt.Sprintf("-!- %s kicked %s (\"%s\")", text.nick, text.args[1], text.args[2]) 129 | } else if text.event == "TOPIC" { 130 | msg = fmt.Sprintf("-!- %s changed topic to \"%s\"", text.nick, text.args[1]) 131 | } 132 | server.WriteChannel(channel, msg) 133 | } 134 | 135 | func (server *Server) WriteChannel(channel string, msg string) { 136 | if msg != "" { 137 | createFiles(server.Dir + "/" + channel) 138 | f, _ := os.OpenFile(server.Dir+"/"+channel+"/out", os.O_RDWR|os.O_APPEND, 0660) 139 | defer f.Close() 140 | t := time.Now() 141 | currTime := fmt.Sprintf("%s", t.Format("2006-01-02 15:04:05")) 142 | _, _ = f.WriteString(currTime + " " + msg + "\n") 143 | } 144 | } 145 | 146 | func (server *Server) Write(msg string) { 147 | server.conn.Write([]byte(msg + "\n")) 148 | } 149 | 150 | func (server *Server) Writef(msg string, arg ...interface{}) { 151 | server.Write(fmt.Sprintf(msg, arg...)) 152 | } 153 | 154 | func (server *Server) listenFile(channel string) { 155 | channel = strings.ToLower(channel) 156 | filePath := server.Dir + "/" + channel 157 | if channel != "" { 158 | filePath = filePath + "/" 159 | } 160 | 161 | createFiles(filePath) 162 | file, err := os.OpenFile(filePath+"in", os.O_CREATE|syscall.O_RDONLY|syscall.O_NONBLOCK, os.ModeNamedPipe) 163 | defer file.Close() 164 | if err != nil { 165 | log.Print("Tried listening on channel:", channel, err) 166 | } 167 | buffer := bufio.NewReader(file) 168 | for { 169 | bytes, _, _ := buffer.ReadLine() 170 | if len(bytes) != 0 { 171 | server.msgChan <- Msg{channel: channel, msg: string(bytes), file: filePath} 172 | } 173 | time.Sleep(10 * time.Millisecond) 174 | } 175 | } 176 | 177 | func (server *Server) listenServer() { 178 | if server.password != "" { 179 | server.Writef("PASS %s", server.password) 180 | } 181 | server.Writef("USER %s 0 * :%s", server.nick, server.realName) 182 | server.Writef("NICK %s", server.nick) 183 | buffer := bufio.NewScanner(server.conn) 184 | for { 185 | for buffer.Scan() { 186 | server.serverChan <- buffer.Text() 187 | if strings.Split(buffer.Text(), " :")[0] == "ERROR" { 188 | return 189 | } 190 | } 191 | } 192 | } 193 | 194 | func (server *Server) createServer() { 195 | var tlsConn net.Conn 196 | var err error 197 | tcpAddr, err := net.ResolveTCPAddr("tcp", fmt.Sprintf("%s:%s", server.server, server.port)) 198 | if err != nil { 199 | log.Fatal("ResolveTCPAddr failed:", err.Error()) 200 | os.Exit(1) 201 | } 202 | conn, err := net.DialTCP("tcp", nil, tcpAddr) 203 | if err != nil { 204 | log.Fatal("Connection blew up:", err) 205 | os.Exit(1) 206 | } 207 | err = conn.SetKeepAlive(true) 208 | if err != nil { 209 | log.Print("Could not set keep alive:", err) 210 | } 211 | if server.tls { 212 | tlsConn = tls.Client(conn, &tls.Config{ 213 | InsecureSkipVerify: true, 214 | }) 215 | } 216 | server.conn = tlsConn 217 | } 218 | 219 | func (server *Server) handleMsg(msg Msg) { 220 | events := strings.SplitN(msg.msg, " ", 3) 221 | // Events 222 | if "/j" == events[0] { 223 | _, ok := server.channels[events[1]] 224 | if ok == false { 225 | server.Writef("JOIN :%s", events[1]) 226 | go server.listenFile(events[1]) 227 | server.channels[events[1]] = true 228 | if len(events) > 2 { 229 | server.Writef("PRIVMSG %s :%s", events[1], events[2]) 230 | } 231 | } 232 | } else if "/a" == events[0] { 233 | server.Writef("AWAY :%s", strings.Join(events[1:], " ")) 234 | } else if "/n" == events[0] { 235 | server.Writef("NICK %s", events[1]) 236 | } else if "/t" == events[0] { 237 | server.Writef("TOPIC %s :%s", events[1], strings.Join(events[2:], " ")) 238 | } else if "/l" == events[0] { 239 | server.Writef("PART %s", events[1]) 240 | delete(server.channels, events[1]) 241 | } else { 242 | server.Writef("PRIVMSG %s :%s", msg.channel, msg.msg) 243 | s := fmt.Sprintf("<%s> %s", server.nick, msg.msg) 244 | server.WriteChannel(msg.channel, s) 245 | } 246 | } 247 | 248 | func (server *Server) rejoinChannels() { 249 | for channel, _ := range server.channels { 250 | server.Writef("JOIN :%s", channel) 251 | } 252 | } 253 | 254 | func (server *Server) handleServer(s string) { 255 | msg := parse(s) 256 | fmt.Println(s) 257 | if msg.event == "ERROR" { 258 | server.createServer() 259 | go server.listenServer() 260 | return 261 | } 262 | if msg.event == "266" { 263 | // Rejoin channels 264 | server.rejoinChannels() 265 | return 266 | } 267 | if msg.event == "PING" { 268 | server.Writef("PONG %s", msg.args[0]) 269 | return 270 | } 271 | if len(msg.nick) == 0 && msg.channel == server.nick || msg.channel == "*" || msg.event == "QUIT" { 272 | server.writeOutLog("", msg) 273 | return 274 | } 275 | var channel string 276 | if msg.channel == server.nick { 277 | channel = strings.ToLower(msg.nick) 278 | } else { 279 | channel = strings.ToLower(msg.channel) 280 | } 281 | // Check if we have a thread on the channel 282 | // Create if there isnt 283 | _, ok := server.channels[channel] 284 | if ok == false { 285 | go server.listenFile(channel) 286 | server.channels[channel] = true 287 | } 288 | server.writeOutLog(channel, msg) 289 | } 290 | 291 | func (server *Server) Run() { 292 | go server.listenServer() 293 | go server.listenFile("") 294 | ticker := time.NewTicker(1 * time.Minute) 295 | for { 296 | select { 297 | case <-ticker.C: 298 | server.Writef("PING %d", time.Now().UnixNano()) 299 | case s := <-server.serverChan: 300 | server.handleServer(s) 301 | case s := <-server.msgChan: 302 | server.handleMsg(s) 303 | } 304 | } 305 | } 306 | 307 | func main() { 308 | server := flag.String("s", "irc.freenode.net", "Specify server") 309 | port := flag.String("p", "", "Server port (default 6667, TLS default 6697)") 310 | tls := flag.Bool("tls", false, "Use TLS for the connection (default false)") 311 | pass := flag.String("k", "IIPASS", "Specify a environment variable for your IRC password") 312 | path := flag.String("i", "", "Specify a path for the IRC connection (default ~/irc)") 313 | nick := flag.String("n", "iii", "Specify a default nick") 314 | realName := flag.String("f", "ii Improved", "Specify a default real name") 315 | flag.Parse() 316 | 317 | if *port == "" { 318 | if *tls { 319 | *port = "6697" 320 | } else { 321 | *port = "6667" 322 | } 323 | } 324 | 325 | if *path == "" { 326 | usr, err := user.Current() 327 | if err != nil { 328 | log.Fatal("Could not get home directory", err) 329 | } 330 | *path = usr.HomeDir + "/irc" 331 | } 332 | 333 | serverRun := Server{ 334 | server: *server, 335 | port: *port, 336 | nick: *nick, 337 | realName: *realName, 338 | password: os.Getenv(*pass), 339 | channels: map[string]bool{}, 340 | msgChan: make(chan Msg), 341 | serverChan: make(chan string), 342 | tls: *tls, 343 | Dir: *path + "/" + *server} 344 | serverRun.createServer() 345 | serverRun.Run() 346 | } 347 | --------------------------------------------------------------------------------