Skip to content

Commit e585962

Browse files
atyeatye
andauthored
Connect to multiple channels (#30)
* support multi channels * updates * update demo * update demo * update demo * update readme * updates * update readme * update readme * update readme * update readme Co-authored-by: atye <aaron.tye@gmail.com>
1 parent 65b21b3 commit e585962

File tree

13 files changed

+430
-302
lines changed

13 files changed

+430
-302
lines changed

README.md

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
# ttchat
22

3-
Connect to a Twitch channel's chat from your terminal
3+
Connect to Twitch channels' chat from your terminal.
4+
5+
![](demo.gif)
46

57
# Installing
68

@@ -20,8 +22,8 @@ You should see the binary at `./bin/ttchat`.
2022
A configuration file at `$HOME/.ttchat/config.yaml` containing some account information is required for authentication.
2123

2224
```
23-
clientID: "your_twitch_client_id"
24-
username: "your_twitch_login_username"
25+
clientID: "yourTwitchClientId"
26+
username: "yourTwitchUsername"
2527
redirectPort: "9999"
2628
lineSpacing: 1
2729
```
@@ -45,14 +47,17 @@ redirectPort: "8080"
4547
`ttchat` would listen on `http://localhost:8080` for Twitch's authentication result. So, your Twitch application must have `http://localhost:8080` for a redirect URL.
4648

4749
# Running
48-
See `ttchat -h` for options and arguments
4950

50-
`ttchat --channel ludwig`
51+
`ttchat --channel sodapoppin`
52+
53+
`ttchat --channel sodapoppin --channel hasanabi`
54+
55+
Obtaining an OAuth access token requires your authorization via web browser. See https://dev.twitch.tv/docs/authentication/getting-tokens-oauth for more details. To provide your own token, use the `--token` flag. The token must have the `chat:edit` and `chat:read` scopes.
5156

52-
Obtaining an access token requires you to login via your default browswer. To provide your own token, use the `--token` flag.
53-
See https://dev.twitch.tv/docs/authentication/getting-tokens-oauth for more details on obtaining your own access token.
57+
`ttchat --channel sodapoppin --token $TOKEN`
5458

55-
`ttchat --channel ludwing --token $ACCESS_TOKEN`
59+
# Usage
5660

57-
## Notes
58-
The token must have the `chat:edit` and `chat:read` scopes
61+
| Syntax | Description |
62+
| ----------- | ----------- |
63+
| Tab/ShiftTab | Next/previous channel |

demo.gif

6.2 MB
Loading

internal/auth/authentication._test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ func TestGetOAuthToken(t *testing.T) {
7171
},
7272
}
7373

74-
tkn, err := GetOAuthToken(conf, verifier{}, u)
74+
tkn, err := GetAccessToken(conf, verifier{}, u)
7575
if err != nil {
7676
t.Fatal(err)
7777
}

internal/auth/authentication.go

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ var (
3232
errNoAccessToken = errors.New("access_token not found")
3333
)
3434

35-
func GetOAuthToken(conf *oauth2.Config, verifier TokenVerifyier, util Utils) (string, error) {
35+
func GetAccessToken(conf *oauth2.Config, verifier TokenVerifyier, util Utils) (string, error) {
3636
state, err := util.NewUUID()
3737
if err != nil {
3838
return "", err
@@ -79,6 +79,30 @@ func GetOAuthToken(conf *oauth2.Config, verifier TokenVerifyier, util Utils) (st
7979
}
8080
}
8181

82+
func ValidateAccessToken(accessToken string) error {
83+
r, err := http.NewRequest("GET", "https://id.twitch.tv/oauth2/validate", nil)
84+
if err != nil {
85+
return err
86+
}
87+
88+
r.Header.Set("Authorization", fmt.Sprintf("OAuth %s", accessToken))
89+
resp, err := http.DefaultClient.Do(r)
90+
if err != nil {
91+
return err
92+
}
93+
defer resp.Body.Close()
94+
95+
if resp.StatusCode == http.StatusUnauthorized {
96+
return fmt.Errorf("unauthorized")
97+
}
98+
99+
if resp.StatusCode != http.StatusOK {
100+
return fmt.Errorf("invaild access token: status code: %d", resp.StatusCode)
101+
}
102+
103+
return nil
104+
}
105+
82106
func buildUserLoginURL(conf *oauth2.Config, state string, nonce string) (string, error) {
83107
authURL, err := url.Parse(conf.AuthCodeURL(state))
84108
if err != nil {
Lines changed: 39 additions & 81 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,13 @@
1-
package cmd
1+
package entrypoint
22

33
import (
44
"context"
5-
"errors"
65
"fmt"
6+
"io"
7+
"log"
78
"math/rand"
8-
"net/http"
99
"os"
1010
"path/filepath"
11-
"strings"
1211
"time"
1312

1413
"github.com/atye/ttchat/internal/auth"
@@ -39,13 +38,6 @@ const (
3938
DefaultRedirectPort = "9999"
4039
)
4140

42-
var (
43-
ErrNoChannel = errors.New("no channel provided")
44-
ErrNoClientID = errors.New("no clientID in configuration file")
45-
ErrNoUsername = errors.New("no username in configuration file")
46-
ErrInvalidAccessToken = errors.New("invalid access token")
47-
)
48-
4941
func NewRootCmd() *cobra.Command {
5042
rootCmd := &cobra.Command{
5143
Use: "ttchat",
@@ -55,22 +47,24 @@ ttchat is a terminal application that connects to a twitch channel's
5547
chat using a small configuration file. See repo for more details.
5648
5749
ttchat -h
58-
ttchat --channel ludwig
59-
ttchat -c ludwing --token $ACCESS_TOKEN
50+
ttchat --channel GothamChess --channel chessbrah
51+
ttchat --channel GothamChess --token $TOKEN
6052
`,
6153
Run: func(cmd *cobra.Command, args []string) {
6254
rand.Seed(time.Now().UTC().UnixNano())
6355

64-
channel, err := cmd.Flags().GetString("channel")
56+
logger := log.New(io.Discard, "", log.LstdFlags)
57+
58+
channels, err := cmd.Flags().GetStringSlice("channel")
6559
if err != nil {
6660
errExit(err)
6761
}
6862

69-
if channel == "" {
70-
errExit(ErrNoChannel)
63+
if len(channels) == 0 {
64+
errExit(fmt.Errorf("no channels provided"))
7165
}
7266

73-
token, err := cmd.Flags().GetString("token")
67+
accessToken, err := cmd.Flags().GetString("token")
7468
if err != nil {
7569
errExit(err)
7670
}
@@ -85,23 +79,24 @@ ttchat -c ludwing --token $ACCESS_TOKEN
8579
errExit(err)
8680
}
8781

88-
provider, err := oidc.NewProvider(context.Background(), "https://id.twitch.tv/oauth2")
89-
if err != nil {
90-
errExit(err)
82+
if accessToken == "" {
83+
provider, err := oidc.NewProvider(context.Background(), "https://id.twitch.tv/oauth2")
84+
if err != nil {
85+
errExit(err)
86+
}
87+
oidcVerifier := openid.CoreOSVerifier{Verifier: provider.Verifier(&oidc.Config{ClientID: conf.ClientID})}
88+
89+
accessToken, err = getAccessToken(logger, conf, oidcVerifier)
90+
if err != nil {
91+
errExit(err)
92+
}
93+
94+
err = auth.ValidateAccessToken(accessToken)
95+
if err != nil {
96+
errExit(err)
97+
}
9198
}
92-
oidcVerifier := openid.CoreOSVerifier{Verifier: provider.Verifier(&oidc.Config{ClientID: conf.ClientID})}
9399

94-
accessToken, err := getAccessToken(token, conf, oidcVerifier)
95-
if err != nil {
96-
errExit(err)
97-
}
98-
99-
err = validateAccessToken(accessToken)
100-
if err != nil {
101-
errExit(err)
102-
}
103-
104-
// Get user display name
105100
tc, err := helix.NewClient(&helix.Options{
106101
ClientID: conf.ClientID,
107102
UserAccessToken: accessToken,
@@ -114,25 +109,26 @@ ttchat -c ludwing --token $ACCESS_TOKEN
114109
if err != nil {
115110
errExit(err)
116111
}
117-
//
118112

119-
// Create IRC client and start
120-
ircClient := client.NewGempirClient(conf.Username, channel, accessToken)
121-
c := irc.NewIRCService(displayName, channel, ircClient)
113+
var channelModels []*terminal.Channel
114+
for _, c := range channels {
115+
conn := irc.NewTwitch(client.NewGempirClient(conf.Username, c, accessToken), logger, displayName, c)
116+
channelModels = append(channelModels, terminal.NewChannel(conn, c, conf.LineSpacing))
117+
}
122118

123-
if tea.NewProgram(terminal.NewModel(c, conf.LineSpacing), tea.WithAltScreen()).Start() != nil {
119+
if tea.NewProgram(terminal.NewModel(logger, channelModels...), tea.WithAltScreen()).Start() != nil {
124120
errExit(err)
125121
}
126122
},
127123
}
128124

129-
rootCmd.Flags().StringP("channel", "c", "", "channel to connect to")
125+
rootCmd.Flags().StringSliceP("channel", "c", []string{}, "channels to connect to")
130126
err := rootCmd.MarkFlagRequired("channel")
131127
if err != nil {
132128
errExit(err)
133129
}
134130

135-
rootCmd.Flags().StringP("token", "t", "", `oauth token of the from "oauth:token" or "token"`)
131+
rootCmd.Flags().StringP("token", "t", "", `provide your own oauth access token to bypass browser login (must have chat:read and chat:edit scopes)`)
136132
return rootCmd
137133
}
138134

@@ -149,11 +145,11 @@ func getConfig(hd string) (Config, error) {
149145
}
150146

151147
if conf.ClientID == "" {
152-
return Config{}, ErrNoClientID
148+
return Config{}, fmt.Errorf("no clientID provided")
153149
}
154150

155151
if conf.Username == "" {
156-
return Config{}, ErrNoUsername
152+
return Config{}, fmt.Errorf("no username provided")
157153
}
158154

159155
if conf.RedirectPort == "" {
@@ -163,21 +159,7 @@ func getConfig(hd string) (Config, error) {
163159
return conf, nil
164160
}
165161

166-
func getAccessToken(tokenFlagValue string, conf Config, verifier auth.TokenVerifyier) (string, error) {
167-
if tokenFlagValue != "" {
168-
s := strings.Split(tokenFlagValue, ":")
169-
switch len(s) {
170-
// oauth:token
171-
case 2:
172-
return s[1], nil
173-
// token
174-
case 1:
175-
return s[0], nil
176-
default:
177-
return "", fmt.Errorf("failed to parse token")
178-
}
179-
}
180-
162+
func getAccessToken(logger *log.Logger, conf Config, verifier auth.TokenVerifyier) (string, error) {
181163
oauthConf := &oauth2.Config{
182164
ClientID: conf.ClientID,
183165
Scopes: []string{"openid", "chat:read", "chat:edit"},
@@ -201,37 +183,13 @@ func getAccessToken(tokenFlagValue string, conf Config, verifier auth.TokenVerif
201183
NewUUID: f,
202184
}
203185

204-
t, err := auth.GetOAuthToken(oauthConf, verifier, u)
186+
t, err := auth.GetAccessToken(oauthConf, verifier, u)
205187
if err != nil {
206188
return "", err
207189
}
208190
return t, nil
209191
}
210192

211-
func validateAccessToken(accessToken string) error {
212-
r, err := http.NewRequest("GET", "https://id.twitch.tv/oauth2/validate", nil)
213-
if err != nil {
214-
return err
215-
}
216-
217-
r.Header.Set("Authorization", fmt.Sprintf("OAuth %s", accessToken))
218-
resp, err := http.DefaultClient.Do(r)
219-
if err != nil {
220-
return err
221-
}
222-
defer resp.Body.Close()
223-
224-
if resp.StatusCode == http.StatusUnauthorized {
225-
return ErrInvalidAccessToken
226-
}
227-
228-
if resp.StatusCode != http.StatusOK {
229-
return fmt.Errorf("invaild access token: status code: %d", resp.StatusCode)
230-
}
231-
232-
return nil
233-
}
234-
235193
type twitchAPI interface {
236194
GetUsers(params *helix.UsersParams) (*helix.UsersResponse, error)
237195
}

internal/irc/client/gempir.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,16 +24,18 @@ func NewGempirClient(username string, channel string, accessToken string) Gempir
2424
return Gempir{irc: c}
2525
}
2626

27-
func (g Gempir) OnPrivateMessage(f func(types.PrivateMessage)) {
27+
func (g Gempir) OnPrivateMessage(f func(types.PrivateMessage)) error {
2828
g.irc.OnPrivateMessage(func(message twitch.PrivateMessage) {
2929
f(types.PrivateMessage{
3030
Name: message.User.DisplayName,
3131
Text: message.Message,
3232
Color: message.User.Color,
3333
})
3434
})
35+
return nil
3536
}
3637

37-
func (g Gempir) Say(channel string, msg string) {
38+
func (g Gempir) Publish(channel string, msg string) error {
3839
g.irc.Say(channel, msg)
40+
return nil
3941
}

0 commit comments

Comments
 (0)