├── .gitignore ├── .golangci.yml ├── .travis.yml ├── LICENSE ├── Makefile ├── README.md ├── cache.go ├── commands.go ├── config.go ├── config.yaml.example ├── go.mod ├── go.sum ├── main.go ├── main_test.go ├── output.go ├── tweets.go ├── utils.go └── version.go /.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | /twet 3 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | linters: 2 | enable-all: true 3 | disable: 4 | - gochecknoglobals 5 | - funlen 6 | - gocognit 7 | - wsl 8 | - gomnd 9 | 10 | linters-settings: 11 | # too see which are enabled: GL_DEBUG=gocritic golangci-lint run 12 | gocritic: 13 | enabled-tags: 14 | - diagnostic 15 | - style 16 | - performance 17 | - experimental 18 | - opinionated 19 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: go 2 | go: 3 | - tip 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Daniel Lublin 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | 2 | BUILDDIR ?= build 3 | 4 | GITVERSION ?= $(shell git describe --tags --long --dirty) 5 | BUILDTIMESTAMP := $(shell date +%s) 6 | 7 | LDFLAGS="\ 8 | -X main.gitVersion=$(GITVERSION) \ 9 | -X main.buildTimestamp=$(BUILDTIMESTAMP)" 10 | 11 | BINARY := twet 12 | SRCS := $(wildcard *.go) 13 | 14 | $(BUILDDIR)/$(BINARY): $(SRCS) 15 | go build -ldflags $(LDFLAGS) -o $@ 16 | 17 | $(BUILDDIR): 18 | mkdir -p $(BUILDDIR) 19 | 20 | lint: 21 | golangci-lint run --enable-all 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # twet 3 | [![Build Status](https://travis-ci.org/quite/twet.svg?branch=master)](https://travis-ci.org/quite/twet) 4 | 5 | twet is a simple client in Go for 6 | [`twtxt`](https://github.com/buckket/twtxt) -- *the decentralised, minimalist 7 | microblogging service for hackers*. 8 | 9 | Please see the [TODO](README.md#todo). 10 | 11 | ## Configuration 12 | 13 | twet looks for `config.yaml` in the following directories. Example 14 | configuration in [`config.yaml.example`](config.yaml.example). 15 | 16 | ``` 17 | $XDG_BASE_DIR/config/twet 18 | $HOME/config/twet 19 | $HOME/Library/Application Support/twet 20 | $HOME/.twet 21 | ``` 22 | 23 | Or you can set a directory with `twet -dir /some/dir`. 24 | 25 | A cache file will be stored next to the config file. 26 | 27 | If you want to read your own tweets, you should follow yourself. The `twturl` 28 | above is used for highlighting mentions, and for revealing who you are in the 29 | HTTP User-Agent when fetching feeds. 30 | 31 | ## TODO? 32 | 33 | * http: think about redirect, and handling of 401, 301, 404? 34 | * cli/http: a "follow" command should probably resolve 301s (cache-control or not?) 35 | * cache: behaviour when adding/removing following 36 | * following: require unique URL? 37 | * ... 38 | -------------------------------------------------------------------------------- /cache.go: -------------------------------------------------------------------------------- 1 | // -*- tab-width: 4; -*- 2 | 3 | package main 4 | 5 | import ( 6 | "bufio" 7 | "bytes" 8 | "encoding/gob" 9 | "fmt" 10 | "io/ioutil" 11 | "log" 12 | "net/http" 13 | "os" 14 | "strings" 15 | "sync" 16 | "time" 17 | 18 | "github.com/schollz/progressbar/v3" 19 | ) 20 | 21 | type Cached struct { 22 | Tweets Tweets 23 | Lastmodified string 24 | } 25 | 26 | // key: url 27 | type Cache map[string]Cached 28 | 29 | func (cache Cache) Store(configpath string) { 30 | b := new(bytes.Buffer) 31 | enc := gob.NewEncoder(b) 32 | err := enc.Encode(cache) 33 | if err != nil { 34 | panic(err) 35 | } 36 | 37 | f, err := os.OpenFile(fmt.Sprintf("%s/cache", configpath), 38 | os.O_CREATE|os.O_WRONLY, 0666) 39 | if err != nil { 40 | panic(err) 41 | } 42 | 43 | defer f.Close() 44 | 45 | if _, err = f.Write(b.Bytes()); err != nil { 46 | panic(err) 47 | } 48 | } 49 | 50 | func CacheLastModified(configpath string) (time.Time, error) { 51 | stat, err := os.Stat(fmt.Sprintf("%s/cache", configpath)) 52 | if err != nil { 53 | if !os.IsNotExist(err) { 54 | return time.Time{}, err 55 | } 56 | return time.Unix(0, 0), nil 57 | } 58 | return stat.ModTime(), nil 59 | } 60 | 61 | func LoadCache(configpath string) Cache { 62 | cache := make(Cache) 63 | 64 | f, err := os.Open(fmt.Sprintf("%s/cache", configpath)) 65 | if err != nil { 66 | if !os.IsNotExist(err) { 67 | panic(err) 68 | } 69 | return cache 70 | } 71 | defer f.Close() 72 | 73 | dec := gob.NewDecoder(f) 74 | err = dec.Decode(&cache) 75 | if err != nil { 76 | panic(err) 77 | } 78 | return cache 79 | } 80 | 81 | const maxfetchers = 50 82 | 83 | func (cache Cache) FetchTweets(sources map[string]string) { 84 | var mu sync.RWMutex 85 | 86 | // progress bar 87 | bar := progressbar.Default(int64(len(sources)), "Updating feeds...") 88 | 89 | // buffered to let goroutines write without blocking before the main thread 90 | // begins reading 91 | tweetsch := make(chan Tweets, len(sources)) 92 | 93 | var wg sync.WaitGroup 94 | // max parallel http fetchers 95 | var fetchers = make(chan struct{}, maxfetchers) 96 | 97 | for nick, url := range sources { 98 | wg.Add(1) 99 | fetchers <- struct{}{} 100 | // anon func takes needed variables as arg, avoiding capture of iterator variables 101 | go func(nick string, url string) { 102 | defer func() { 103 | <-fetchers 104 | bar.Add(1) 105 | wg.Done() 106 | }() 107 | 108 | if strings.HasPrefix(url, "file://") { 109 | err := ReadLocalFile(url, nick, tweetsch, cache, &mu) 110 | if err != nil { 111 | if debug { 112 | log.Printf("%s: Failed to read and cache local file: %s", url, err) 113 | } 114 | } 115 | return 116 | } 117 | 118 | req, err := http.NewRequest("GET", url, nil) 119 | if err != nil { 120 | if debug { 121 | log.Printf("%s: http.NewRequest fail: %s", url, err) 122 | } 123 | tweetsch <- nil 124 | return 125 | } 126 | 127 | if conf.Nick != "" && conf.Twturl != "" && conf.DiscloseIdentity { 128 | if debug { 129 | log.Printf("Disclosing Identity...\n") 130 | } 131 | req.Header.Set("User-Agent", 132 | fmt.Sprintf("%s/%s (+%s; @%s)", progname, GetVersion(), 133 | conf.Twturl, conf.Nick)) 134 | } 135 | 136 | mu.RLock() 137 | if cached, ok := cache[url]; ok { 138 | if cached.Lastmodified != "" { 139 | req.Header.Set("If-Modified-Since", cached.Lastmodified) 140 | } 141 | } 142 | mu.RUnlock() 143 | 144 | client := http.Client{ 145 | Timeout: time.Second * 15, 146 | } 147 | resp, err := client.Do(req) 148 | if err != nil { 149 | if debug { 150 | log.Printf("%s: client.Do fail: %s", url, err) 151 | } 152 | tweetsch <- nil 153 | return 154 | } 155 | defer resp.Body.Close() 156 | 157 | actualurl := resp.Request.URL.String() 158 | if actualurl != url { 159 | if debug { 160 | log.Printf("feed for %s changed from %s to %s", nick, url, actualurl) 161 | } 162 | url = actualurl 163 | conf.Following[nick] = url 164 | if err := conf.Write(); err != nil { 165 | if debug { 166 | log.Printf("%s: conf.Write fail: %s", url, err) 167 | } 168 | tweetsch <- nil 169 | return 170 | } 171 | } 172 | 173 | var tweets Tweets 174 | 175 | switch resp.StatusCode { 176 | case http.StatusOK: // 200 177 | scanner := bufio.NewScanner(resp.Body) 178 | tweets = ParseFile(scanner, Tweeter{Nick: nick, URL: url}) 179 | lastmodified := resp.Header.Get("Last-Modified") 180 | mu.Lock() 181 | cache[url] = Cached{Tweets: tweets, Lastmodified: lastmodified} 182 | mu.Unlock() 183 | case http.StatusNotModified: // 304 184 | mu.RLock() 185 | tweets = cache[url].Tweets 186 | mu.RUnlock() 187 | } 188 | 189 | tweetsch <- tweets 190 | }(nick, url) 191 | } 192 | 193 | // close tweets channel when all goroutines are done 194 | go func() { 195 | wg.Wait() 196 | close(tweetsch) 197 | }() 198 | 199 | if debug { 200 | log.Print("fetching:\n") 201 | } 202 | 203 | var n = 0 204 | // loop until channel closed 205 | for tweets := range tweetsch { 206 | n++ 207 | if debug { 208 | log.Printf("%d ", len(sources)+1-n) 209 | } 210 | if debug && len(tweets) > 0 { 211 | log.Printf("%s\n", tweets[0].Tweeter.URL) 212 | } 213 | } 214 | if debug { 215 | log.Print("\n") 216 | } 217 | } 218 | 219 | func ReadLocalFile(url, nick string, tweetsch chan<- Tweets, cache Cache, mu sync.Locker) error { 220 | path := url[6:] 221 | file, err := os.Stat(path) 222 | if err != nil { 223 | if debug { 224 | log.Printf("%s: Can't stat local file: %s", path, err) 225 | } 226 | return err 227 | } 228 | if cached, ok := (cache)[url]; ok { 229 | if cached.Lastmodified == file.ModTime().String() { 230 | tweets := (cache)[url].Tweets 231 | tweetsch <- tweets 232 | return nil 233 | } 234 | } 235 | data, err := ioutil.ReadFile(path) 236 | if err != nil { 237 | if debug { 238 | log.Printf("%s: Can't read local file: %s", path, err) 239 | } 240 | tweetsch <- nil 241 | return err 242 | } 243 | scanner := bufio.NewScanner(bytes.NewReader(data)) 244 | tweets := ParseFile(scanner, Tweeter{Nick: nick, URL: url}) 245 | lastmodified := file.ModTime().String() 246 | mu.Lock() 247 | cache[url] = Cached{Tweets: tweets, Lastmodified: lastmodified} 248 | mu.Unlock() 249 | tweetsch <- tweets 250 | return nil 251 | } 252 | 253 | func (cache Cache) GetAll() Tweets { 254 | var alltweets Tweets 255 | for url, cached := range cache { 256 | alltweets = append(alltweets, cached.Tweets...) 257 | if debug { 258 | log.Printf("%s\n", url) 259 | } 260 | } 261 | return alltweets 262 | } 263 | 264 | func (cache Cache) GetByURL(url string) Tweets { 265 | if cached, ok := cache[url]; ok { 266 | return cached.Tweets 267 | } 268 | return Tweets{} 269 | } 270 | -------------------------------------------------------------------------------- /commands.go: -------------------------------------------------------------------------------- 1 | // -*- tab-width: 4; -*- 2 | 3 | package main 4 | 5 | import ( 6 | "flag" 7 | "fmt" 8 | "log" 9 | "os" 10 | "regexp" 11 | "sort" 12 | "strings" 13 | "time" 14 | 15 | "github.com/peterh/liner" 16 | ) 17 | 18 | func FollowingCommand(args []string) error { 19 | fs := flag.NewFlagSet("following", flag.ContinueOnError) 20 | fs.SetOutput(os.Stdout) 21 | rawFlag := fs.Bool("r", false, "output following users in machine parsable format") 22 | 23 | fs.Usage = func() { 24 | fmt.Printf("usage: %s following [arguments]\n\nDisplays a list of users being followed.\n\n", progname) 25 | fs.PrintDefaults() 26 | } 27 | if err := fs.Parse(args); err != nil { 28 | if err == flag.ErrHelp { 29 | return nil 30 | } 31 | return fmt.Errorf("error parsing arguments") 32 | } 33 | 34 | if fs.NArg() > 0 { 35 | return fmt.Errorf("too many arguments given") 36 | } 37 | 38 | for nick, url := range conf.Following { 39 | if *rawFlag { 40 | PrintFolloweeRaw(nick, url) 41 | } else { 42 | PrintFollowee(nick, url) 43 | } 44 | fmt.Println() 45 | } 46 | 47 | return nil 48 | } 49 | 50 | func FollowCommand(args []string) error { 51 | fs := flag.NewFlagSet("follow", flag.ContinueOnError) 52 | fs.SetOutput(os.Stdout) 53 | 54 | fs.Usage = func() { 55 | fmt.Printf("usage: %s follow \n\nStart following @.\n\n", progname) 56 | fs.PrintDefaults() 57 | } 58 | if err := fs.Parse(args); err != nil { 59 | if err == flag.ErrHelp { 60 | return nil 61 | } 62 | return fmt.Errorf("error parsing arguments") 63 | } 64 | 65 | if fs.NArg() < 2 { 66 | return fmt.Errorf("too few arguments given") 67 | } 68 | 69 | nick := fs.Args()[0] 70 | url := fs.Args()[1] 71 | 72 | conf.Following[nick] = url 73 | if err := conf.Write(); err != nil { 74 | return fmt.Errorf("error: writing config failed with %s", err) 75 | } 76 | 77 | fmt.Printf("%s successfully started following %s @ %s", yellow("✓"), blue(nick), url) 78 | 79 | return nil 80 | } 81 | 82 | func UnfollowCommand(args []string) error { 83 | fs := flag.NewFlagSet("unfollow", flag.ContinueOnError) 84 | fs.SetOutput(os.Stdout) 85 | 86 | fs.Usage = func() { 87 | fmt.Printf("usage: %s unfollow \n\nStop following @nick.\n\n", progname) 88 | fs.PrintDefaults() 89 | } 90 | if err := fs.Parse(args); err != nil { 91 | if err == flag.ErrHelp { 92 | return nil 93 | } 94 | return fmt.Errorf("error parsing arguments") 95 | } 96 | if fs.NArg() < 1 { 97 | return fmt.Errorf("too few arguments given") 98 | } 99 | 100 | nick := fs.Args()[0] 101 | delete(conf.Following, nick) 102 | if err := conf.Write(); err != nil { 103 | return fmt.Errorf("error: writing config failed with %s", err) 104 | } 105 | 106 | fmt.Printf("%s successfully stopped following %s", yellow("✓"), blue(nick)) 107 | 108 | return nil 109 | } 110 | 111 | func TimelineCommand(args []string) error { 112 | fs := flag.NewFlagSet("timeline", flag.ContinueOnError) 113 | fs.SetOutput(os.Stdout) 114 | durationFlag := fs.Duration("d", 0, "only show tweets created at most `duration` back in time. Example: -d 12h") 115 | sourceFlag := fs.String("s", "", "only show timeline for given nick (URL, if dry-run)") 116 | fullFlag := fs.Bool("f", false, "display full timeline (overrides timeline config)") 117 | dryFlag := fs.Bool("n", false, "dry-run, only locally cached tweets") 118 | rawFlag := fs.Bool("r", false, "output tweets in URL-prefixed twtxt format") 119 | reversedFlag := fs.Bool("desc", false, "tweets shown in descending order (newer tweets at top)") 120 | 121 | fs.Usage = func() { 122 | fmt.Printf("usage: %s timeline [arguments]\n\nDisplays the timeline.\n\n", progname) 123 | fs.PrintDefaults() 124 | } 125 | if err := fs.Parse(args); err != nil { 126 | if err == flag.ErrHelp { 127 | return nil 128 | } 129 | return fmt.Errorf("error parsing arguments") 130 | } 131 | if fs.NArg() > 0 { 132 | return fmt.Errorf("too many arguments given") 133 | } 134 | if *durationFlag < 0 { 135 | return fmt.Errorf("negative duration doesn't make sense") 136 | } 137 | if *fullFlag { 138 | if *durationFlag > 0 { 139 | return fmt.Errorf("full timeline with duration makes no sense") 140 | } 141 | conf.Timeline = "full" 142 | } 143 | 144 | cache := LoadCache(configpath) 145 | cacheLastModified, err := CacheLastModified(configpath) 146 | if err != nil { 147 | return fmt.Errorf("error calculating last modified cache time: %s", err) 148 | } 149 | 150 | var sourceURL string 151 | 152 | if !*dryFlag { 153 | var sources = conf.Following 154 | 155 | if conf.IncludeYourself { 156 | sources[conf.Nick] = conf.Twturl 157 | } 158 | 159 | if *sourceFlag != "" { 160 | url, ok := conf.Following[*sourceFlag] 161 | if !ok { 162 | return fmt.Errorf("no source with nick %q", *sourceFlag) 163 | } 164 | sources = make(map[string]string) 165 | sources[*sourceFlag] = url 166 | sourceURL = url 167 | } 168 | 169 | cache.FetchTweets(sources) 170 | cache.Store(configpath) 171 | 172 | // Did the url for *sourceFlag change? 173 | if sources[*sourceFlag] != conf.Following[*sourceFlag] { 174 | sources[*sourceFlag] = conf.Following[*sourceFlag] 175 | sourceURL = conf.Following[*sourceFlag] 176 | } 177 | } 178 | 179 | if debug && *dryFlag { 180 | log.Print("dry run\n") 181 | } 182 | 183 | var tweets Tweets 184 | if *sourceFlag != "" { 185 | tweets = cache.GetByURL(sourceURL) 186 | } else { 187 | for _, url := range conf.Following { 188 | tweets = append(tweets, cache.GetByURL(url)...) 189 | } 190 | } 191 | if *reversedFlag { 192 | sort.Sort(sort.Reverse(tweets)) 193 | } else { 194 | sort.Sort(tweets) 195 | } 196 | 197 | now := time.Now() 198 | for _, tweet := range tweets { 199 | if (*durationFlag > 0 && now.Sub(tweet.Created) <= *durationFlag) || 200 | (conf.Timeline == "full" && *durationFlag == 0) || 201 | (conf.Timeline == "new" && tweet.Created.Sub(cacheLastModified) >= 0) { 202 | if !*rawFlag { 203 | PrintTweet(tweet, now) 204 | } else { 205 | PrintTweetRaw(tweet) 206 | } 207 | fmt.Println() 208 | } 209 | } 210 | 211 | return nil 212 | } 213 | 214 | func TweetCommand(args []string) error { 215 | fs := flag.NewFlagSet("tweet", flag.ContinueOnError) 216 | fs.SetOutput(os.Stdout) 217 | fs.Usage = func() { 218 | fmt.Printf(`usage: %s tweet [words] 219 | or: %s twet [words] 220 | 221 | Adds a new tweet to your twtfile. Words are joined together with a single 222 | space. If no words are given, user will be prompted to input the text 223 | interactively. 224 | `, progname, progname) 225 | fs.PrintDefaults() 226 | } 227 | if err := fs.Parse(args); err != nil { 228 | if err == flag.ErrHelp { 229 | return nil 230 | } 231 | return fmt.Errorf("error parsing arguments") 232 | } 233 | 234 | twtfile := conf.Twtfile 235 | if twtfile == "" { 236 | return fmt.Errorf("cannot tweet without twtfile set in config") 237 | } 238 | // We don't support shell style ~user/foo.txt :P 239 | if strings.HasPrefix(twtfile, "~/") { 240 | twtfile = strings.Replace(twtfile, "~", homedir, 1) 241 | } 242 | 243 | var text string 244 | if fs.NArg() == 0 { 245 | var err error 246 | if text, err = getLine(); err != nil { 247 | return fmt.Errorf("readline: %v", err) 248 | } 249 | } else { 250 | text = strings.Join(fs.Args(), " ") 251 | } 252 | text = strings.TrimSpace(text) 253 | if text == "" { 254 | return fmt.Errorf("cowardly refusing to tweet empty text, or only spaces") 255 | } 256 | text = fmt.Sprintf("%s\t%s\n", time.Now().Format(time.RFC3339), ExpandMentions(text)) 257 | f, err := os.OpenFile(twtfile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0666) 258 | if err != nil { 259 | return err 260 | } 261 | defer f.Close() 262 | 263 | var n int 264 | if n, err = f.WriteString(text); err != nil { 265 | return err 266 | } 267 | fmt.Printf("appended %d bytes to %s:\n%s", n, conf.Twtfile, text) 268 | 269 | return nil 270 | } 271 | 272 | func getLine() (string, error) { 273 | l := liner.NewLiner() 274 | defer l.Close() 275 | l.SetCtrlCAborts(true) 276 | l.SetMultiLineMode(true) 277 | l.SetTabCompletionStyle(liner.TabCircular) 278 | l.SetBeep(false) 279 | 280 | var tags, nicks []string 281 | for tag := range LoadCache(configpath).GetAll().Tags() { 282 | tags = append(tags, tag) 283 | } 284 | sort.Strings(tags) 285 | for nick := range conf.Following { 286 | nicks = append(nicks, nick) 287 | } 288 | sort.Strings(nicks) 289 | 290 | l.SetCompleter(func(line string) (candidates []string) { 291 | i := strings.LastIndexAny(line, "@#") 292 | if i == -1 { 293 | return 294 | } 295 | 296 | vocab := nicks 297 | if line[i] == '#' { 298 | vocab = tags 299 | } 300 | i++ 301 | 302 | for _, item := range vocab { 303 | if strings.HasPrefix(strings.ToLower(item), strings.ToLower(line[i:])) { 304 | candidates = append(candidates, line[:i]+item) 305 | } 306 | } 307 | 308 | return 309 | }) 310 | 311 | return l.Prompt("> ") 312 | } 313 | 314 | // Turns "@nick" into "@" if we're following nick. 315 | func ExpandMentions(text string) string { 316 | re := regexp.MustCompile(`@([_a-zA-Z0-9]+)`) 317 | return re.ReplaceAllStringFunc(text, func(match string) string { 318 | parts := re.FindStringSubmatch(match) 319 | mentionednick := parts[1] 320 | 321 | for followednick, followedurl := range conf.Following { 322 | if mentionednick == followednick { 323 | return fmt.Sprintf("@<%s %s>", followednick, followedurl) 324 | } 325 | } 326 | // Not expanding if we're not following 327 | return match 328 | }) 329 | } 330 | -------------------------------------------------------------------------------- /config.go: -------------------------------------------------------------------------------- 1 | // -*- tab-width: 4; -*- 2 | 3 | package main 4 | 5 | import ( 6 | "errors" 7 | "fmt" 8 | "io/ioutil" 9 | "log" 10 | "os" 11 | "path/filepath" 12 | "strings" 13 | 14 | "github.com/go-yaml/yaml" 15 | ) 16 | 17 | type Hooks struct { 18 | Pre string 19 | Post string 20 | } 21 | 22 | type Config struct { 23 | Nick string 24 | Twturl string 25 | Twtfile string 26 | Following map[string]string // nick -> url 27 | DiscloseIdentity bool 28 | Timeline string 29 | Hooks Hooks 30 | IncludeYourself bool 31 | nicks map[string]string // normalizeURL(url) -> nick 32 | path string // location of loaded config 33 | } 34 | 35 | func (conf *Config) Write() error { 36 | if conf.path == "" { 37 | return errors.New("error: no config file path found") 38 | } 39 | 40 | data, err := yaml.Marshal(conf) 41 | if err != nil { 42 | return fmt.Errorf("error marshalling config: %s", err) 43 | } 44 | 45 | return ioutil.WriteFile(conf.path, data, 0666) 46 | } 47 | 48 | func (conf *Config) Parse(data []byte) error { 49 | return yaml.Unmarshal(data, conf) 50 | } 51 | 52 | func (conf *Config) Read(confdir string) string { 53 | var paths []string 54 | if confdir != "" { 55 | paths = append(paths, confdir) 56 | } else { 57 | if xdg := os.Getenv("XDG_CONFIG_HOME"); xdg != "" { 58 | paths = append(paths, fmt.Sprintf("%s/twet", xdg)) 59 | } 60 | paths = append(paths, 61 | fmt.Sprintf("%s/.config/twet", homedir), 62 | fmt.Sprintf("%s/Library/Application Support/twet", homedir), 63 | fmt.Sprintf("%s/.twet", homedir)) 64 | } 65 | 66 | filename := "config.yaml" 67 | 68 | foundpath := "" 69 | for _, path := range paths { 70 | configfile := fmt.Sprintf("%s/%s", path, filename) 71 | data, err := ioutil.ReadFile(configfile) 72 | if err != nil { 73 | // try next path 74 | continue 75 | } 76 | if err := conf.Parse(data); err != nil { 77 | log.Fatal(fmt.Sprintf("error parsing config file: %s: %s", filename, err)) 78 | } 79 | foundpath = path 80 | break 81 | } 82 | if foundpath == "" { 83 | log.Fatal(fmt.Sprintf("config file %q not found; looked in: %q", filename, paths)) 84 | } 85 | 86 | conf.Timeline = strings.ToLower(conf.Timeline) 87 | if conf.Timeline != "new" && conf.Timeline != "full" { 88 | log.Fatal(fmt.Sprintf("unexpected config timeline: %s", conf.Timeline)) 89 | } 90 | 91 | conf.path = filepath.Join(foundpath, filename) 92 | return foundpath 93 | } 94 | 95 | func (conf *Config) urlToNick(url string) string { 96 | if conf.nicks == nil { 97 | conf.nicks = make(map[string]string) 98 | for n, u := range conf.Following { 99 | if u = NormalizeURL(u); u == "" { 100 | continue 101 | } 102 | conf.nicks[u] = n 103 | } 104 | if conf.Nick != "" && conf.Twturl != "" { 105 | conf.nicks[NormalizeURL(conf.Twturl)] = conf.Nick 106 | } 107 | } 108 | return conf.nicks[NormalizeURL(url)] 109 | } 110 | -------------------------------------------------------------------------------- /config.yaml.example: -------------------------------------------------------------------------------- 1 | 2 | # Define yourself! This is the author: 3 | nick: quite 4 | twturl: https://lublin.se/twtxt.txt 5 | 6 | # Tweets are appended here. 7 | twtfile: ~/public_html/twtxt.txt 8 | 9 | # When fetching feeds over HTTP, add User-Agent header with nick and program version. 10 | #discloseidentity: true 11 | 12 | # Include your own posts in the timeline. 13 | #includeyourself: false 14 | 15 | # Timeline command 16 | # full - display all tweets (default) 17 | # new - only new tweets since last sync 18 | #timeline: full 19 | 20 | # Execute some shell command before/after tweeting. 21 | #hooks: 22 | # pre: scp remote:twtxt.txt ~/twtxt.txt 23 | # post: scp ~/twtxt.txt remote:twtxt.txt 24 | 25 | # Follow some twtxters! 26 | following: 27 | quite: https://lublin.se/twtxt.txt 28 | example: https://example.com/non-existant.txt 29 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/quite/twet 2 | 3 | go 1.12 4 | 5 | require ( 6 | github.com/go-yaml/yaml v2.1.0+incompatible 7 | github.com/goware/urlx v0.3.1 8 | github.com/kr/pretty v0.1.0 // indirect 9 | github.com/peterh/liner v1.2.0 10 | github.com/schollz/progressbar/v3 v3.3.4 11 | golang.org/x/net v0.0.0-20200202094626-16171245cfb2 // indirect 12 | golang.org/x/text v0.3.2 // indirect 13 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect 14 | gopkg.in/yaml.v2 v2.2.7 // indirect 15 | ) 16 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/PuerkitoBio/purell v1.1.1 h1:WEQqlqaGbrPkxLJWfBwQmfEAE1Z7ONdDLqrN38tNFfI= 2 | github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0= 3 | github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 h1:d+Bc7a5rLufV/sSk/8dngufqelfh6jnri85riMAaF/M= 4 | github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE= 5 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 6 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 7 | github.com/go-yaml/yaml v2.1.0+incompatible h1:RYi2hDdss1u4YE7GwixGzWwVo47T8UQwnTLB6vQiq+o= 8 | github.com/go-yaml/yaml v2.1.0+incompatible/go.mod h1:w2MrLa16VYP0jy6N7M5kHaCkaLENm+P+Tv+MfurjSw0= 9 | github.com/goware/urlx v0.3.1 h1:BbvKl8oiXtJAzOzMqAQ0GfIhf96fKeNEZfm9ocNSUBI= 10 | github.com/goware/urlx v0.3.1/go.mod h1:h8uwbJy68o+tQXCGZNa9D73WN8n0r9OBae5bUnLcgjw= 11 | github.com/k0kubun/go-ansi v0.0.0-20180517002512-3bf9e2903213/go.mod h1:vNUNkEQ1e29fT/6vq2aBdFsgNPmy8qMdSay1npru+Sw= 12 | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 13 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 14 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 15 | github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= 16 | github.com/mattn/go-runewidth v0.0.3 h1:a+kO+98RDGEfo6asOGMmpodZq4FNtnGP54yps8BzLR4= 17 | github.com/mattn/go-runewidth v0.0.3/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= 18 | github.com/mattn/go-runewidth v0.0.9 h1:Lm995f3rfxdpd6TSmuVCHVb/QhupuXlYr8sCI/QdE+0= 19 | github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= 20 | github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db h1:62I3jR2EmQ4l5rM/4FEfDWcRD+abF5XlKShorW5LRoQ= 21 | github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db/go.mod h1:l0dey0ia/Uv7NcFFVbCLtqEBQbrT4OCwCSKTEv6enCw= 22 | github.com/peterh/liner v1.2.0 h1:w/UPXyl5GfahFxcTOz2j9wCIHNI+pUPr2laqpojKNCg= 23 | github.com/peterh/liner v1.2.0/go.mod h1:CRroGNssyjTd/qIG2FyxByd2S8JEAZXBl4qUrZf8GS0= 24 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 25 | github.com/schollz/progressbar/v3 v3.3.4 h1:nMinx+JaEm/zJz4cEyClQeAw5rsYSB5th3xv+5lV6Vg= 26 | github.com/schollz/progressbar/v3 v3.3.4/go.mod h1:Rp5lZwpgtYmlvmGo1FyDwXMqagyRBQYSDwzlP9QDu84= 27 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 28 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 29 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 30 | golang.org/x/net v0.0.0-20190213061140-3a22650c66bd h1:HuTn7WObtcDo9uEEU7rEqL0jYthdXAmZ6PP+meazmaU= 31 | golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 32 | golang.org/x/net v0.0.0-20200202094626-16171245cfb2 h1:CCH4IOTTfewWjGOlSp+zGcjutRKlBEZQ6wTn8ozI/nI= 33 | golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 34 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 35 | golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 36 | golang.org/x/sys v0.0.0-20200615200032-f1bc736245b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 37 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 38 | golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs= 39 | golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= 40 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 41 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 42 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= 43 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 44 | gopkg.in/yaml.v2 v2.2.7 h1:VUgggvou5XRW9mHwD/yXxIYSMtY0zoKQf/v226p2nyo= 45 | gopkg.in/yaml.v2 v2.2.7/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 46 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | // -*- tab-width: 4; -*- 2 | 3 | package main 4 | 5 | import ( 6 | "flag" 7 | "fmt" 8 | "log" 9 | "os" 10 | ) 11 | 12 | const progname = "twet" 13 | 14 | var homedir string 15 | var conf Config = Config{ 16 | DiscloseIdentity: true, 17 | Timeline: "full", 18 | } 19 | var configpath string 20 | 21 | var debug bool 22 | var dir string 23 | var usage = fmt.Sprintf(`%s is a client for twtxt -- https://twtxt.readthedocs.org/en/stable/ 24 | 25 | Usage: 26 | %s [flags] command [arguments] 27 | 28 | Commands: 29 | following 30 | follow 31 | unfollow 32 | timeline 33 | tweet or twet 34 | 35 | Use "%s help [command]" for more information about a command. 36 | 37 | Flags: 38 | `, progname, progname, progname) 39 | 40 | func main() { 41 | log.SetPrefix(fmt.Sprintf("%s: ", progname)) 42 | log.SetFlags(0) 43 | 44 | if homedir = os.Getenv("HOME"); homedir == "" { 45 | log.Fatal("HOME env variable empty?! can't proceed") 46 | } 47 | 48 | flag.CommandLine.SetOutput(os.Stdout) 49 | flag.BoolVar(&debug, "debug", false, "output debug info") 50 | flag.StringVar(&dir, "dir", "", "set config directory") 51 | flag.Usage = func() { 52 | fmt.Print(usage) 53 | flag.PrintDefaults() 54 | } 55 | flag.Parse() 56 | configpath = conf.Read(dir) 57 | 58 | switch flag.Arg(0) { 59 | case "following": 60 | if err := FollowingCommand(flag.Args()[1:]); err != nil { 61 | log.Fatal(err) 62 | } 63 | case "follow": 64 | if err := FollowCommand(flag.Args()[1:]); err != nil { 65 | log.Fatal(err) 66 | } 67 | case "unfollow": 68 | if err := UnfollowCommand(flag.Args()[1:]); err != nil { 69 | log.Fatal(err) 70 | } 71 | case "timeline": 72 | if err := TimelineCommand(flag.Args()[1:]); err != nil { 73 | log.Fatal(err) 74 | } 75 | case "tweet", "twet": 76 | if conf.Hooks.Pre != "" { 77 | if _, err := execShell(homedir, conf.Hooks.Pre); err != nil { 78 | log.Fatalf("error executing pre tweet hook: %s", err) 79 | } 80 | } 81 | 82 | if err := TweetCommand(flag.Args()[1:]); err != nil { 83 | log.Fatal(err) 84 | } 85 | 86 | if conf.Hooks.Post != "" { 87 | if _, err := execShell(homedir, conf.Hooks.Post); err != nil { 88 | log.Fatalf("error executing post tweet hook: %s", err) 89 | } 90 | } 91 | case "help": 92 | switch flag.Arg(1) { 93 | case "following": 94 | _ = FollowingCommand([]string{"-h"}) 95 | case "follow": 96 | _ = FollowCommand([]string{"-h"}) 97 | case "unfollow": 98 | _ = UnfollowCommand([]string{"-h"}) 99 | case "timeline": 100 | _ = TimelineCommand([]string{"-h"}) 101 | case "tweet", "twet": 102 | _ = TweetCommand([]string{"-h"}) 103 | case "": 104 | flag.Usage() 105 | os.Exit(2) 106 | default: 107 | log.Printf("Unknown help topic %q.\n", flag.Arg(1)) 108 | os.Exit(2) 109 | } 110 | case "version": 111 | fmt.Printf("%s %s\n", progname, GetVersion()) 112 | case "": 113 | flag.Usage() 114 | os.Exit(2) 115 | default: 116 | log.Fatal(fmt.Sprintf("%q is not a valid command.\n", flag.Arg(0))) 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /main_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | ) 7 | 8 | var testsPrettyDuration = []struct { 9 | in time.Duration 10 | out string 11 | }{ 12 | {time.Second * 0, "0s ago"}, 13 | {time.Second * 1, "1s ago"}, 14 | {time.Second * 2, "2s ago"}, 15 | {time.Minute*1 + time.Second*0, "1m ago"}, 16 | {time.Minute*1 + time.Second*1, "1m ago"}, 17 | {time.Minute*1 + time.Second*2, "1m ago"}, 18 | {time.Minute*2 + time.Second*0, "2m ago"}, 19 | {time.Minute*2 + time.Second*1, "2m ago"}, 20 | {time.Minute*2 + time.Second*2, "2m ago"}, 21 | {time.Hour*1 + time.Minute*0, "1h ago"}, 22 | {time.Hour*1 + time.Minute*1, "1h 1m ago"}, 23 | {time.Hour*1 + time.Minute*2, "1h 2m ago"}, 24 | {time.Hour*2 + time.Minute*0, "2h ago"}, 25 | {time.Hour*2 + time.Minute*1, "2h 1m ago"}, 26 | {time.Hour*2 + time.Minute*2, "2h 2m ago"}, 27 | {time.Hour*24 + time.Minute*0, "1d ago"}, 28 | {time.Hour*24 + time.Minute*1, "1d ago"}, 29 | {time.Hour*24 + time.Minute*2, "1d ago"}, 30 | {time.Hour*24 + time.Minute*60, "1d 1h ago"}, 31 | {time.Hour*24 + time.Minute*120, "1d 2h ago"}, 32 | {time.Hour*24*2 + time.Minute*0, "2d ago"}, 33 | {time.Hour*24*2 + time.Minute*1, "2d ago"}, 34 | {time.Hour*24*2 + time.Minute*2, "2d ago"}, 35 | {time.Hour*24*2 + time.Minute*60, "2d 1h ago"}, 36 | {time.Hour*24*2 + time.Minute*120, "2d 2h ago"}, 37 | {time.Hour*24*6 + time.Minute*0, "6d ago"}, 38 | {time.Hour*24*6 + time.Minute*1, "6d ago"}, 39 | {time.Hour*24*6 + time.Minute*2, "6d ago"}, 40 | {time.Hour*24*6 + time.Minute*60, "6d 1h ago"}, 41 | {time.Hour*24*6 + time.Minute*120, "6d 2h ago"}, 42 | {time.Hour*24*7 + time.Minute*0, "7d ago"}, 43 | {time.Hour*24*7 + time.Minute*1, "7d ago"}, 44 | {time.Hour*24*7 + time.Minute*2, "7d ago"}, 45 | {time.Hour*24*7 + time.Minute*60, "7d ago"}, 46 | {time.Hour*24*7 + time.Minute*120, "7d ago"}, 47 | {time.Hour*24*14 + time.Minute*0, "2w ago"}, 48 | {time.Hour*24*14 + time.Minute*1, "2w ago"}, 49 | {time.Hour*24*365 + time.Minute*0, "1y 0w ago"}, 50 | {time.Hour*24*(365+7*1) + time.Minute*0, "1y 1w ago"}, 51 | {time.Hour*24*(365+7*2) + time.Minute*0, "1y 2w ago"}, 52 | } 53 | 54 | func TestPrettyDuration(t *testing.T) { 55 | for _, tt := range testsPrettyDuration { 56 | out := PrettyDuration(tt.in) 57 | if out != tt.out { 58 | t.Errorf("pretty_duration(%q) => %q, want %q", tt.in, out, tt.out) 59 | } 60 | } 61 | } 62 | 63 | var testsNormalizeURL = []struct { 64 | in string 65 | out string 66 | }{ 67 | {"https://example.org", "http://example.org"}, 68 | {"http://example.org:80", "http://example.org"}, 69 | {"https://example.org:443", "http://example.org"}, 70 | {"http://example.org/", "http://example.org"}, 71 | {"http://example.org/bar/", "http://example.org/bar"}, 72 | {"http://example.org/bar", "http://example.org/bar"}, 73 | {"http://example.org/bar/../quux", "http://example.org/quux"}, 74 | {"http://example.org/b%61r", "http://example.org/bar"}, 75 | {"http://example.org/b%6F%6f", "http://example.org/boo"}, 76 | {"http://bob:s3cr3t@example.org/", "http://example.org"}, 77 | } 78 | 79 | func TestNormalizeURL(t *testing.T) { 80 | for _, tt := range testsNormalizeURL { 81 | out := NormalizeURL(tt.in) 82 | if out != tt.out { 83 | t.Errorf("normalizeURL(%q) => %q, want %q", tt.in, out, tt.out) 84 | } 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /output.go: -------------------------------------------------------------------------------- 1 | // -*- tab-width: 4; -*- 2 | 3 | package main 4 | 5 | import ( 6 | "fmt" 7 | "regexp" 8 | "strings" 9 | "time" 10 | 11 | "github.com/goware/urlx" 12 | ) 13 | 14 | func red(s string) string { 15 | return fmt.Sprintf("\033[31m%s\033[0m", s) 16 | } 17 | func green(s string) string { 18 | return fmt.Sprintf("\033[32m%s\033[0m", s) 19 | } 20 | func yellow(s string) string { 21 | return fmt.Sprintf("\033[33m%s\033[0m", s) 22 | } 23 | func boldgreen(s string) string { 24 | return fmt.Sprintf("\033[32;1m%s\033[0m", s) 25 | } 26 | func blue(s string) string { 27 | return fmt.Sprintf("\033[34m%s\033[0m", s) 28 | } 29 | 30 | func PrintFollowee(nick, url string) { 31 | fmt.Printf("> %s @ %s", 32 | yellow(nick), 33 | url, 34 | ) 35 | } 36 | 37 | func PrintFolloweeRaw(nick, url string) { 38 | fmt.Printf("%s: %s\n", nick, url) 39 | } 40 | 41 | func PrintTweet(tweet Tweet, now time.Time) { 42 | text := ShortenMentions(tweet.Text) 43 | 44 | nick := green(tweet.Tweeter.Nick) 45 | if NormalizeURL(tweet.Tweeter.URL) == NormalizeURL(conf.Twturl) { 46 | nick = boldgreen(tweet.Tweeter.Nick) 47 | } 48 | fmt.Printf("> %s (%s)\n%s\n", 49 | nick, 50 | PrettyDuration(now.Sub(tweet.Created)), 51 | text) 52 | } 53 | 54 | func PrintTweetRaw(tweet Tweet) { 55 | fmt.Printf("%s\t%s\t%s", 56 | tweet.Tweeter.URL, 57 | tweet.Created.Format(time.RFC3339), 58 | tweet.Text) 59 | } 60 | 61 | // Turns "@" into "@nick" if we're following URL (or it's us!). If 62 | // we're following as another nick then "@nick(followednick)". 63 | func ShortenMentions(text string) string { 64 | re := regexp.MustCompile(`@<([^ ]+) *([^>]+)>`) 65 | return re.ReplaceAllStringFunc(text, func(match string) string { 66 | parts := re.FindStringSubmatch(match) 67 | nick, url := parts[1], parts[2] 68 | if fnick := conf.urlToNick(url); fnick != "" { 69 | return FormatMention(nick, url, fnick) 70 | } 71 | // Not shortening if we're not following 72 | return match 73 | }) 74 | } 75 | 76 | func NormalizeURL(url string) string { 77 | if url == "" { 78 | return "" 79 | } 80 | u, err := urlx.Parse(url) 81 | if err != nil { 82 | return "" 83 | } 84 | if u.Scheme == "https" { 85 | u.Scheme = "http" 86 | u.Host = strings.TrimSuffix(u.Host, ":443") 87 | } 88 | u.User = nil 89 | u.Path = strings.TrimSuffix(u.Path, "/") 90 | norm, err := urlx.Normalize(u) 91 | if err != nil { 92 | return "" 93 | } 94 | return norm 95 | } 96 | 97 | // Takes followednick to be able to indicated when somebody (URL) was mentioned 98 | // using a nick other than the one we follow the person as. 99 | func FormatMention(nick, url, followednick string) string { 100 | str := "@" + nick 101 | if followednick != nick { 102 | str += fmt.Sprintf("(%s)", followednick) 103 | } 104 | if NormalizeURL(url) == NormalizeURL(conf.Twturl) { 105 | return red(str) 106 | } 107 | return blue(str) 108 | } 109 | 110 | func PrettyDuration(duration time.Duration) string { 111 | s := int(duration.Seconds()) 112 | d := s / 86400 113 | s %= 86400 114 | if d >= 365 { 115 | return fmt.Sprintf("%dy %dw ago", d/365, d%365/7) 116 | } 117 | if d >= 14 { 118 | return fmt.Sprintf("%dw ago", d/7) 119 | } 120 | h := s / 3600 121 | s %= 3600 122 | if d > 0 { 123 | str := fmt.Sprintf("%dd", d) 124 | if h > 0 && d <= 6 { 125 | str += fmt.Sprintf(" %dh", h) 126 | } 127 | return str + " ago" 128 | } 129 | m := s / 60 130 | s %= 60 131 | if h > 0 || m > 0 { 132 | str := "" 133 | hh := "" 134 | if h > 0 { 135 | str += fmt.Sprintf("%dh", h) 136 | hh = " " 137 | } 138 | if m > 0 { 139 | str += fmt.Sprintf("%s%dm", hh, m) 140 | } 141 | return str + " ago" 142 | } 143 | return fmt.Sprintf("%ds ago", s) 144 | } 145 | -------------------------------------------------------------------------------- /tweets.go: -------------------------------------------------------------------------------- 1 | // -*- tab-width: 4; -*- 2 | 3 | package main 4 | 5 | import ( 6 | "bufio" 7 | "log" 8 | "regexp" 9 | "strings" 10 | "time" 11 | ) 12 | 13 | type Tweeter struct { 14 | Nick string 15 | URL string 16 | } 17 | type Tweet struct { 18 | Tweeter Tweeter 19 | Created time.Time 20 | Text string 21 | } 22 | 23 | // typedef to be able to attach sort methods 24 | type Tweets []Tweet 25 | 26 | func (tweets Tweets) Len() int { 27 | return len(tweets) 28 | } 29 | func (tweets Tweets) Less(i, j int) bool { 30 | return tweets[i].Created.Before(tweets[j].Created) 31 | } 32 | func (tweets Tweets) Swap(i, j int) { 33 | tweets[i], tweets[j] = tweets[j], tweets[i] 34 | } 35 | 36 | func (tweets Tweets) Tags() map[string]int { 37 | tags := make(map[string]int) 38 | re := regexp.MustCompile(`#[-\w]+`) 39 | for _, tweet := range tweets { 40 | for _, tag := range re.FindAllString(tweet.Text, -1) { 41 | tags[strings.TrimLeft(tag, "#")]++ 42 | } 43 | } 44 | return tags 45 | } 46 | 47 | func ParseFile(scanner *bufio.Scanner, tweeter Tweeter) Tweets { 48 | var tweets Tweets 49 | re := regexp.MustCompile(`^(.+?)(\s+)(.+)$`) // .+? is ungreedy 50 | for scanner.Scan() { 51 | line := scanner.Text() 52 | if line == "" { 53 | continue 54 | } 55 | if strings.HasPrefix(line, "#") { 56 | continue 57 | } 58 | parts := re.FindStringSubmatch(line) 59 | // "Submatch 0 is the match of the entire expression, submatch 1 the 60 | // match of the first parenthesized subexpression, and so on." 61 | if len(parts) != 4 { 62 | if debug { 63 | log.Printf("could not parse: '%s' (source:%s)\n", line, tweeter.URL) 64 | } 65 | continue 66 | } 67 | tweets = append(tweets, 68 | Tweet{ 69 | Tweeter: tweeter, 70 | Created: ParseTime(parts[1]), 71 | Text: parts[3], 72 | }) 73 | } 74 | if err := scanner.Err(); err != nil { 75 | panic(err) 76 | } 77 | return tweets 78 | } 79 | 80 | func ParseTime(timestr string) time.Time { 81 | var tm time.Time 82 | var err error 83 | // Twtxt clients generally uses basically time.RFC3339Nano, but sometimes 84 | // there's a colon in the timezone, or no timezone at all. 85 | for _, layout := range []string{ 86 | "2006-01-02T15:04:05.999999999Z07:00", 87 | "2006-01-02T15:04:05.999999999Z0700", 88 | "2006-01-02T15:04:05.999999999", 89 | "2006-01-02T15:04.999999999Z07:00", 90 | "2006-01-02T15:04.999999999Z0700", 91 | "2006-01-02T15:04.999999999", 92 | } { 93 | tm, err = time.Parse(layout, strings.ToUpper(timestr)) 94 | if err != nil { 95 | continue 96 | } else { 97 | break 98 | } 99 | } 100 | if err != nil { 101 | return time.Unix(0, 0) 102 | } 103 | return tm 104 | } 105 | -------------------------------------------------------------------------------- /utils.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "io" 5 | "os/exec" 6 | "syscall" 7 | ) 8 | 9 | type execResult struct { 10 | io.ReadCloser 11 | Status int 12 | Output []byte 13 | readIndex int64 14 | } 15 | 16 | func (res *execResult) Close() error { 17 | return nil 18 | } 19 | 20 | func (res *execResult) Read(p []byte) (n int, err error) { 21 | if res.readIndex >= int64(len(res.Output)) { 22 | err = io.EOF 23 | return 24 | } 25 | 26 | n = copy(p, res.Output[res.readIndex:]) 27 | res.readIndex += int64(n) 28 | return 29 | } 30 | 31 | func execShell(dir, cmd string) (res *execResult, err error) { 32 | res = &execResult{} 33 | 34 | sh := exec.Command("/bin/sh", "-c", cmd) 35 | if dir != "" { 36 | sh.Dir = dir 37 | } 38 | 39 | res.Output, err = sh.CombinedOutput() 40 | if err != nil { 41 | 42 | // Shamelessly borrowed from https://github.com/prologic/je/blob/master/job.go#L247 43 | if exiterr, ok := err.(*exec.ExitError); ok { 44 | // The program has exited with an exit code != 0 45 | 46 | // This works on both Unix and Windows. Although package 47 | // syscall is generally platform dependent, WaitStatus is 48 | // defined for both Unix and Windows and in both cases has 49 | // an ExitStatus() method with the same signature. 50 | if status, ok := exiterr.Sys().(syscall.WaitStatus); ok { 51 | res.Status = status.ExitStatus() 52 | } 53 | } 54 | } 55 | 56 | return 57 | } 58 | -------------------------------------------------------------------------------- /version.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | "strings" 7 | "time" 8 | ) 9 | 10 | func GetVersion() string { 11 | if e, err := strconv.ParseInt(buildTimestamp, 10, 64); err == nil { 12 | buildTimestamp = time.Unix(e, 0).Format(time.RFC3339) 13 | } 14 | return fmt.Sprintf("%s built from %s at %s", 15 | strings.TrimPrefix(version, "v"), gitVersion, 16 | buildTimestamp) 17 | } 18 | 19 | var ( 20 | version = "v1.3.0" 21 | gitVersion = "unknown-git-version" 22 | buildTimestamp = "unknown-time" 23 | ) 24 | --------------------------------------------------------------------------------