feat: usos sign-in, private calendar feed and timetable archive

This commit is contained in:
hh committed 2026-10-11 00:47:38 +00:00
commit 7d4e21f977
33 files changed
+3270

No files matched your search

+362
View File
@@ -0,0 +1,362 @@
package usos
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
_ "time/tzdata"
)
const (
Scopes = "studies|offline_access"
activityFields = "type|start_time|end_time|name|url|course_id|course_name|classtype_name|lecturer_ids|group_number|building_name|building_id|room_number|unit_id|sm_id"
commonFields = "type|start_time|end_time|name|url"
timeLayout = "2006-01-02 15:04:05"
dateLayout = "2006-01-02"
responseLimit = 8 << 20
lecturerBatch = 20
)
var (
ErrUnauthorized = errors.New("usos: access token rejected")
Location = mustLocation("Europe/Warsaw")
)
func mustLocation(name string) *time.Location {
location, err := time.LoadLocation(name)
if err != nil {
panic(err)
}
return location
}
type Error struct {
Status int
Message string
}
func (e *Error) Error() string {
return fmt.Sprintf("usos: http %d: %s", e.Status, e.Message)
}
type Token struct {
Key string
Secret string
}
type Client struct {
BaseURL string
Key string
Secret string
HTTP *http.Client
}
func New(baseURL, key, secret string) *Client {
return &Client{
BaseURL: strings.TrimRight(baseURL, "/"),
Key: key,
Secret: secret,
HTTP: &http.Client{Timeout: 30 * time.Second},
}
}
func (c *Client) call(ctx context.Context, method string, token *Token, params url.Values) ([]byte, error) {
endpoint := c.BaseURL + "/services/" + method
header, body := c.authorization(endpoint, token, params)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(body.Encode()))
if err != nil {
return nil, err
}
req.Header.Set("Authorization", header)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
res, err := c.HTTP.Do(req)
if err != nil {
return nil, fmt.Errorf("usos: %s: %w", method, err)
}
defer res.Body.Close()
data, err := io.ReadAll(io.LimitReader(res.Body, responseLimit))
if err != nil {
return nil, fmt.Errorf("usos: %s: %w", method, err)
}
if res.StatusCode == http.StatusUnauthorized {
return nil, ErrUnauthorized
}
if res.StatusCode != http.StatusOK {
var failure struct {
Message string `json:"message"`
}
_ = json.Unmarshal(data, &failure)
return nil, &Error{Status: res.StatusCode, Message: method + ": " + failure.Message}
}
return data, nil
}
func (c *Client) tokenCall(ctx context.Context, method string, token *Token, params url.Values) (Token, error) {
data, err := c.call(ctx, method, token, params)
if err != nil {
return Token{}, err
}
values, err := url.ParseQuery(strings.TrimSpace(string(data)))
if err != nil || values.Get("oauth_token") == "" || values.Get("oauth_token_secret") == "" {
return Token{}, &Error{Status: http.StatusOK, Message: method + ": malformed token response"}
}
return Token{Key: values.Get("oauth_token"), Secret: values.Get("oauth_token_secret")}, nil
}
func (c *Client) RequestToken(ctx context.Context, callback string) (Token, error) {
return c.tokenCall(ctx, "oauth/request_token", nil, url.Values{"oauth_callback": {callback}, "scopes": {Scopes}})
}
func (c *Client) AuthorizeURL(request Token) string {
return c.BaseURL + "/services/oauth/authorize?oauth_token=" + url.QueryEscape(request.Key)
}
func (c *Client) AccessToken(ctx context.Context, request Token, verifier string) (Token, error) {
return c.tokenCall(ctx, "oauth/access_token", &request, url.Values{"oauth_verifier": {verifier}})
}
func (c *Client) Revoke(ctx context.Context, token Token) error {
_, err := c.call(ctx, "oauth/revoke_token", &token, nil)
return err
}
type Person struct {
ID Scalar `json:"id"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
Titles struct {
Before string `json:"before"`
After string `json:"after"`
} `json:"titles"`
}
func (p Person) Name() string {
return strings.Join(strings.Fields(p.Titles.Before+" "+p.FirstName+" "+p.LastName+" "+p.Titles.After), " ")
}
func (c *Client) Me(ctx context.Context, token Token) (Person, error) {
var person Person
data, err := c.call(ctx, "users/user", &token, url.Values{"fields": {"id|first_name|last_name"}})
if err != nil {
return person, err
}
if err := json.Unmarshal(data, &person); err != nil || person.ID == "" {
return person, &Error{Status: http.StatusOK, Message: "users/user: malformed response"}
}
return person, nil
}
func (c *Client) Lecturers(ctx context.Context, ids []string) (map[string]string, error) {
names := map[string]string{}
for start := 0; start < len(ids); start += lecturerBatch {
batch := ids[start:min(start+lecturerBatch, len(ids))]
data, err := c.call(ctx, "users/users", nil, url.Values{
"user_ids": {strings.Join(batch, "|")},
"fields": {"id|first_name|last_name|titles"},
})
if err != nil {
return names, err
}
var people map[string]*Person
if err := json.Unmarshal(data, &people); err != nil {
return names, &Error{Status: http.StatusOK, Message: "users/users: malformed response"}
}
for id, person := range people {
if person != nil {
names[id] = person.Name()
}
}
}
return names, nil
}
type Building struct {
Name LangDict `json:"name"`
Address string `json:"postal_address"`
}
func (c *Client) Building(ctx context.Context, id string) (Building, error) {
var building Building
data, err := c.call(ctx, "geo/building2", nil, url.Values{"building_id": {id}, "fields": {"id|name|postal_address"}})
if err != nil {
return building, err
}
if err := json.Unmarshal(data, &building); err != nil {
return building, &Error{Status: http.StatusOK, Message: "geo/building2: malformed response"}
}
return building, nil
}
type Term struct {
Start time.Time
Finish time.Time
}
func (c *Client) Terms(ctx context.Context, startsBy, finishesFrom time.Time) ([]Term, error) {
data, err := c.call(ctx, "terms/search", nil, url.Values{
"max_start_date": {startsBy.Format(dateLayout)},
"min_finish_date": {finishesFrom.Format(dateLayout)},
})
if err != nil {
return nil, err
}
var raw []struct {
Start string `json:"start_date"`
Finish string `json:"finish_date"`
}
if err := json.Unmarshal(data, &raw); err != nil {
return nil, &Error{Status: http.StatusOK, Message: "terms/search: malformed response"}
}
terms := make([]Term, 0, len(raw))
for _, item := range raw {
start, startErr := time.ParseInLocation(dateLayout, item.Start, Location)
finish, finishErr := time.ParseInLocation(dateLayout, item.Finish, Location)
if startErr == nil && finishErr == nil {
terms = append(terms, Term{Start: start, Finish: finish})
}
}
return terms, nil
}
type Activity struct {
Type string
Start time.Time
End time.Time
Name string
URL string
CourseID string
CourseName string
ClasstypeName string
LecturerIDs []string
GroupNumber string
BuildingName string
BuildingID string
RoomNumber string
UnitID string
MeetingID string
}
type rawActivity struct {
Type string `json:"type"`
StartTime string `json:"start_time"`
EndTime string `json:"end_time"`
Name LangDict `json:"name"`
URL Scalar `json:"url"`
CourseID Scalar `json:"course_id"`
CourseName LangDict `json:"course_name"`
ClasstypeName LangDict `json:"classtype_name"`
LecturerIDs []Scalar `json:"lecturer_ids"`
GroupNumber Scalar `json:"group_number"`
BuildingName LangDict `json:"building_name"`
BuildingID Scalar `json:"building_id"`
RoomNumber Scalar `json:"room_number"`
UnitID Scalar `json:"unit_id"`
MeetingID Scalar `json:"sm_id"`
}
func (c *Client) Timetable(ctx context.Context, token Token, start time.Time, days int) ([]Activity, error) {
params := url.Values{
"start": {start.Format(dateLayout)},
"days": {strconv.Itoa(days)},
"fields": {activityFields},
}
data, err := c.call(ctx, "tt/user", &token, params)
var failure *Error
if errors.As(err, &failure) && failure.Status >= http.StatusInternalServerError {
params.Set("fields", commonFields)
data, err = c.call(ctx, "tt/user", &token, params)
}
if err != nil {
return nil, err
}
var raw []rawActivity
if err := json.Unmarshal(data, &raw); err != nil {
return nil, &Error{Status: http.StatusOK, Message: "tt/user: malformed response"}
}
activities := make([]Activity, 0, len(raw))
for _, item := range raw {
begin, beginErr := time.ParseInLocation(timeLayout, item.StartTime, Location)
end, endErr := time.ParseInLocation(timeLayout, item.EndTime, Location)
if beginErr != nil || endErr != nil {
continue
}
lecturers := make([]string, 0, len(item.LecturerIDs))
for _, id := range item.LecturerIDs {
lecturers = append(lecturers, string(id))
}
activities = append(activities, Activity{
Type: item.Type,
Start: begin,
End: end,
Name: item.Name.String(),
URL: string(item.URL),
CourseID: string(item.CourseID),
CourseName: item.CourseName.String(),
ClasstypeName: item.ClasstypeName.String(),
LecturerIDs: lecturers,
GroupNumber: string(item.GroupNumber),
BuildingName: item.BuildingName.String(),
BuildingID: string(item.BuildingID),
RoomNumber: string(item.RoomNumber),
UnitID: string(item.UnitID),
MeetingID: string(item.MeetingID),
})
}
return activities, nil
}
type Scalar string
func (s *Scalar) UnmarshalJSON(data []byte) error {
data = bytes.TrimSpace(data)
if len(data) == 0 || string(data) == "null" {
*s = ""
return nil
}
if data[0] == '"' {
var text string
if err := json.Unmarshal(data, &text); err != nil {
return err
}
*s = Scalar(text)
return nil
}
if data[0] == '{' || data[0] == '[' {
*s = ""
return nil
}
*s = Scalar(data)
return nil
}
type LangDict struct {
PL string
EN string
}
func (l *LangDict) UnmarshalJSON(data []byte) error {
var raw struct {
PL Scalar `json:"pl"`
EN Scalar `json:"en"`
}
if json.Unmarshal(data, &raw) == nil {
l.PL, l.EN = strings.TrimSpace(string(raw.PL)), strings.TrimSpace(string(raw.EN))
}
return nil
}
func (l LangDict) String() string {
if l.PL != "" {
return l.PL
}
return l.EN
}