~hokiegeek/fitstrava

ref: f5bac16231013630f1c8e6996ecfbe398a034d23 fitstrava/strava.go -rw-r--r-- 13.7 KiB
f5bac162HokieGeek Done and done 4 months ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
package fitstrava

import (
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"net/url"
	"strconv"
	"time"
)

func stravaCodeFromWeb(clientID string) string {
	port := 8686
	return codeFromWeb(port,
		fmt.Sprintf("https://www.strava.com/oauth/authorize?client_id=%s&redirect_uri=%s%d&response_type=code&approval_prompt=auto&scope=%s", clientID, `https%3A%2F%2Flocalhost%3A`, port, `activity%3Aread_all%2Cactivity%3Awrite`))
}

type stravaAuthToken struct {
	TokenType    string  `json:"token_type"`
	ExpiresAt    int64   `json:"expires_at"`
	ExpiresIn    int64   `json:"expires_in"`
	RefreshToken string  `json:"refresh_token"`
	AccessToken  string  `json:"access_token"`
	Athlete      Athlete `json:"athlete"`
}

type Athlete struct {
	ID                    int64         `json:"id"`
	Username              string        `json:"username"`
	ResourceState         int64         `json:"resource_state"`
	Firstname             string        `json:"firstname"`
	Lastname              string        `json:"lastname"`
	City                  string        `json:"city"`
	State                 string        `json:"state"`
	Country               string        `json:"country"`
	Sex                   string        `json:"sex"`
	Premium               bool          `json:"premium"`
	CreatedAt             string        `json:"created_at"`
	UpdatedAt             string        `json:"updated_at"`
	BadgeTypeID           int64         `json:"badge_type_id"`
	ProfileMedium         string        `json:"profile_medium"`
	Profile               string        `json:"profile"`
	Friend                interface{}   `json:"friend"`
	Follower              interface{}   `json:"follower"`
	FollowerCount         int64         `json:"follower_count"`
	FriendCount           int64         `json:"friend_count"`
	MutualFriendCount     int64         `json:"mutual_friend_count"`
	AthleteType           int64         `json:"athlete_type"`
	DatePreference        string        `json:"date_preference"`
	MeasurementPreference string        `json:"measurement_preference"`
	Clubs                 []interface{} `json:"clubs"`
	FTP                   interface{}   `json:"ftp"`
	Weight                float64       `json:"weight"`
	Bikes                 []Bike        `json:"bikes"`
	Shoes                 []Bike        `json:"shoes"`
}

type Bike struct {
	ID            string `json:"id"`
	Primary       bool   `json:"primary"`
	Name          string `json:"name"`
	ResourceState int64  `json:"resource_state"`
	Distance      int64  `json:"distance"`
}

func getStravaAuthToken(clientID, clientSecret, authCode string) (stravaAuthToken, error) {
	resp, err := http.PostForm("https://www.strava.com/api/v3/oauth/token",
		url.Values{"client_id": {clientID}, "client_secret": {clientSecret}, "code": {authCode}, "grant_type": {"authorization_code"}})
	if err != nil {
		return stravaAuthToken{}, err
	}
	defer resp.Body.Close()

	if resp.StatusCode != http.StatusOK {
		return stravaAuthToken{}, fmt.Errorf("fitbit returned non-ok status: %s", resp.Status)
	}

	body, err := io.ReadAll(resp.Body)
	if err != nil {
		return stravaAuthToken{}, err
	}

	var token stravaAuthToken
	if err := json.Unmarshal(body, &token); err != nil {
		return stravaAuthToken{}, err
	}

	return token, nil
}

type StravaClient struct {
	clientID, clientSecret string
	Token                  stravaAuthToken
}

func NewStravaClient(clientID, clientSecret string) (StravaClient, error) {
	code := stravaCodeFromWeb(clientID)

	token, err := getStravaAuthToken(clientID, clientSecret, code)
	if err != nil {
		panic(err)
	}

	return StravaClient{clientID: clientID, clientSecret: clientSecret, Token: token}, nil
}

type stravaErrorResponse struct {
	Message string        `json:"message"`
	Errors  []stravaError `json:"errors"`
}

type stravaError struct {
	Resource string `json:"resource"`
	Field    string `json:"field"`
	Code     string `json:"code"`
}

func stravaHttp(strava StravaClient, method, url string, body io.Reader) (*http.Response, error) {
	client := &http.Client{}
	r, err := http.NewRequest(method, url, body)
	if err != nil {
		return nil, err
	}
	r.Header.Add("Accept", "application/json")
	r.Header.Add("Authorization", fmt.Sprintf("Bearer %s", strava.Token.AccessToken))

	return client.Do(r)
}

func stravaGet(strava StravaClient, url string) (*http.Response, error) {
	return stravaHttp(strava, http.MethodGet, url, nil)
}

func stravaPostForm(strava StravaClient, url string, params url.Values) (*http.Response, error) {
	return stravaHttp(strava, http.MethodPost, url+"?"+params.Encode(), nil)
}

type stravaAthleteActivitiesResponse []StravaActivity

type StravaActivity struct {
	ResourceState              int64             `json:"resource_state"`
	Athlete                    Athlete           `json:"athlete"`
	Name                       string            `json:"name"`
	Distance                   float64           `json:"distance"`
	MovingTime                 int64             `json:"moving_time"`
	ElapsedTime                int64             `json:"elapsed_time"`
	TotalElevationGain         float64           `json:"total_elevation_gain"`
	Type                       string            `json:"type"`
	WorkoutType                interface{}       `json:"workout_type,omitempty"`
	ID                         int64             `json:"id"`
	ExternalID                 string            `json:"external_id,omitempty"`
	UploadID                   int64             `json:"upload_id,omitempty"`
	StartDate                  string            `json:"start_date"`
	StartDateLocal             string            `json:"start_date_local"`
	Timezone                   string            `json:"timezone"`
	UTCOffset                  float64           `json:"utc_offset"`
	StartLatlng                interface{}       `json:"start_latlng"`
	EndLatlng                  interface{}       `json:"end_latlng"`
	LocationCity               interface{}       `json:"location_city"`
	LocationState              interface{}       `json:"location_state"`
	LocationCountry            string            `json:"location_country"`
	AchievementCount           int64             `json:"achievement_count"`
	KudosCount                 int64             `json:"kudos_count"`
	CommentCount               int64             `json:"comment_count"`
	AthleteCount               int64             `json:"athlete_count"`
	PhotoCount                 int64             `json:"photo_count"`
	Map                        Map               `json:"map"`
	Trainer                    bool              `json:"trainer"`
	Commute                    bool              `json:"commute"`
	Manual                     bool              `json:"manual"`
	Private                    bool              `json:"private"`
	Visibility                 string            `json:"visibility"`
	Flagged                    bool              `json:"flagged"`
	GearID                     interface{}       `json:"gear_id"`
	StartLatitude              interface{}       `json:"start_latitude"`
	StartLongitude             interface{}       `json:"start_longitude"`
	AverageSpeed               float64           `json:"average_speed"`
	MaxSpeed                   float64           `json:"max_speed"`
	AverageCadence             float64           `json:"average_cadence,omitempty"`
	AverageWatts               float64           `json:"average_watts,omitempty"`
	WeightedAverageWatts       int64             `json:"weighted_average_watts,omitempty"`
	Kilojoules                 float64           `json:"kilojoules,omitempty"`
	DeviceWatts                bool              `json:"device_watts,omitempty"`
	HasHeartrate               bool              `json:"has_heartrate"`
	AverageHeartrate           float64           `json:"average_heartrate,omitempty"`
	MaxHeartrate               float64           `json:"max_heartrate,omitempty"`
	MaxWatts                   int64             `json:"max_watts,omitempty"`
	HeartrateOptOut            bool              `json:"heartrate_opt_out"`
	DisplayHideHeartrateOption bool              `json:"display_hide_heartrate_option"`
	FromAcceptedTag            bool              `json:"from_accepted_tag"`
	PRCount                    int64             `json:"pr_count"`
	TotalPhotoCount            int64             `json:"total_photo_count"`
	HasKudoed                  bool              `json:"has_kudoed"`
	SufferScore                int64             `json:"suffer_score,omitempty"`
	Description                string            `json:"description"`
	Calories                   float64           `json:"calories"`
	PerceivedExertion          interface{}       `json:"perceived_exertion"`
	PreferPerceivedExertion    interface{}       `json:"prefer_perceived_exertion"`
	SegmentEfforts             []interface{}     `json:"segment_efforts"`
	Photos                     Photos            `json:"photos"`
	StatsVisibility            []StatsVisibility `json:"stats_visibility"`
	HideFromHome               bool              `json:"hide_from_home"`
	EmbedToken                 string            `json:"embed_token"`
	PrivateNote                interface{}       `json:"private_note"`
	AvailableZones             []interface{}     `json:"available_zones"`
}

type Photos struct {
	Primary interface{} `json:"primary"`
	Count   int64       `json:"count"`
}

type StatsVisibility struct {
	Type       string `json:"type"`
	Visibility string `json:"visibility"`
}

type Map struct {
	ID              string      `json:"id"`
	SummaryPolyline interface{} `json:"summary_polyline"`
	ResourceState   int64       `json:"resource_state"`
}

func stravaHttpError(body io.ReadCloser, msg string) error {
	var serr stravaErrorResponse
	if err := json.NewDecoder(body).Decode(&serr); err != nil {
		return fmt.Errorf("%s", msg)
	}
	return fmt.Errorf("%s: %s", msg, serr.Message)
}

func StravaGetActivities(strava StravaClient, after time.Time) ([]StravaActivity, error) {
	activities := make([]StravaActivity, 0)

	page := 1
	for {
		qp := url.Values{}
		qp.Set("after", strconv.FormatInt(after.Unix(), 10))
		qp.Set("page", strconv.Itoa(page))
		qp.Set("per_page", "100")

		resp, err := stravaGet(strava, "https://www.strava.com/api/v3/athlete/activities?"+qp.Encode())
		if err != nil {
			return nil, err
		}
		defer resp.Body.Close()

		if resp.StatusCode != http.StatusOK {
			if resp.StatusCode == http.StatusTooManyRequests {
				// X-Ratelimit-Limit:[100,1000]
				// X-Ratelimit-Usage:[224,251]
				fmt.Printf("%+v\n", resp.Header)
			}
			return nil, stravaHttpError(resp.Body, fmt.Sprintf("could not get Strava activities: %s", resp.Status))
			/*
				var serr stravaErrorResponse
				if err := json.NewDecoder(resp.Body).Decode(&serr); err != nil {
					return nil, fmt.Errorf("could not get Strava activities: %s", resp.Status)
				}
				return nil, fmt.Errorf("could not get Strava activities: %s: %s", resp.Status, serr.Message)
			*/
		}

		var stravaResp stravaAthleteActivitiesResponse
		if err := json.NewDecoder(resp.Body).Decode(&stravaResp); err != nil {
			return nil, err
		}

		if len(stravaResp) == 0 {
			break
		}

		activities = append(activities, stravaResp...)
		page = page + 1
	}

	return activities, nil
}

/*
	name required String, in form 	The name of the activity.
	type required String, in form 	Type of activity. For example - Run, Ride etc.
	start_date_local required Date, in form 	ISO 8601 formatted date time.
	elapsed_time required Integer, in form 	In seconds.
	description String, in form 	Description of the activity.
	distance Float, in form 	In meters.
*/
type StravaNewActivity struct {
	Name, Type, Description string
	StartDate               time.Time
	ElapsedSeconds          int64
	// Distance float64
}

func StravaCreateActivity(strava StravaClient, activity StravaNewActivity) error {
	p := url.Values{}
	p.Set("name", activity.Name)
	p.Set("type", activity.Type)
	p.Set("description", activity.Description)
	p.Set("start_date_local", activity.StartDate.Format(time.RFC3339))
	p.Set("elapsed_time", strconv.FormatInt(activity.ElapsedSeconds, 10))
	p.Set("hide_from_home", "true")
	p.Set("external_id", "42")

	// p.Set("perceived_exertion", "2")
	// p.Set("private_note", "this is private")

	resp, err := stravaPostForm(strava, "https://www.strava.com/api/v3/activities", p)
	if err != nil {
		return err
	}
	defer resp.Body.Close()

	if resp.StatusCode != http.StatusCreated {
		/*
			var serr stravaErrorResponse
			if err := json.NewDecoder(resp.Body).Decode(&serr); err != nil {
				return fmt.Errorf("could not create Strava activity: %s", resp.Status)
			}
			return fmt.Errorf("could not create Strava activity: %s: %s", resp.Status, serr.Message)
		*/
		return stravaHttpError(resp.Body, fmt.Sprintf("could not create Strava activity: %s", resp.Status))
	}

	var newactivity StravaActivity
	if err := json.NewDecoder(resp.Body).Decode(&newactivity); err != nil {
		return fmt.Errorf("could not parse new Strava activity: %v", err)
	}

	/*
		p = url.Values{}
		p.Set("_method", "patch")
		p.Set("perceived_exertion", "2")
		p.Set("private_note", "this is private")

		// https://www.strava.com/activities/6786571466
		resp, err = stravaPut(strava, fmt.Sprintf("https://www.strava.com/api/v3/activities/%d", newactivity.ID), p)
		if err != nil {
			return err
		}

		if resp.StatusCode != http.StatusFound {
			return fmt.Errorf("could not post a patch: %s", resp.Status)
		}
	*/

	return nil
}

func StravaGetActivityByID(strava StravaClient, id int64) (StravaActivity, error) {
	resp, err := stravaGet(strava, fmt.Sprintf("https://www.strava.com/api/v3/activities/%d", id))
	if err != nil {
		return StravaActivity{}, err
	}
	defer resp.Body.Close()

	if resp.StatusCode != http.StatusOK {
		return StravaActivity{}, stravaHttpError(resp.Body, fmt.Sprintf("could not create Strava activity: %s", resp.Status))
	}

	var activity StravaActivity
	if err := json.NewDecoder(resp.Body).Decode(&activity); err != nil {
		return StravaActivity{}, fmt.Errorf("could not parse Strava activity: %v", err)
	}

	return activity, nil
}