diff --git a/pkg/connector/handle_message.go b/pkg/connector/handle_message.go index 2c7fb29..add703d 100644 --- a/pkg/connector/handle_message.go +++ b/pkg/connector/handle_message.go @@ -32,6 +32,7 @@ func (lc *LineClient) newMessageHandler() *handlers.Handler { IsLoggedOut: lc.isLoggedOut, NewClient: func() *line.Client { return line.NewClient(lc.AccessToken) }, DecryptMedia: lc.decryptImageData, + IsAnimatedGif: isAnimatedGif, } } diff --git a/pkg/connector/handlers/handler.go b/pkg/connector/handlers/handler.go index 12b1a66..95b276d 100644 --- a/pkg/connector/handlers/handler.go +++ b/pkg/connector/handlers/handler.go @@ -25,6 +25,9 @@ type Handler struct { // DecryptMedia decrypts E2EE encrypted media data using the given key material. DecryptMedia func(data []byte, keyMaterial string) ([]byte, error) + + // IsAnimatedGif checks if the given data is an animated GIF. + IsAnimatedGif func(data []byte) bool } // tryRecoverClient attempts token recovery on auth errors and returns a fresh client. diff --git a/pkg/connector/handlers/image.go b/pkg/connector/handlers/image.go index 173041d..cad4d26 100644 --- a/pkg/connector/handlers/image.go +++ b/pkg/connector/handlers/image.go @@ -1,9 +1,11 @@ package handlers import ( + "bytes" "context" "encoding/json" "fmt" + "image" "strings" "time" @@ -28,9 +30,77 @@ func (h *Handler) ConvertImage(ctx context.Context, portal *bridgev2.Portal, int return nil, nil } + // MEDIA_CONTENT_INFO marks animated GIFs, which need the original OBS object. + metadataAnimated := false + var metadataWidth, metadataHeight int + if mediaInfo := data.ContentMetadata["MEDIA_CONTENT_INFO"]; mediaInfo != "" { + var info struct { + Animated bool `json:"animated"` + Width int `json:"width"` + Height int `json:"height"` + } + if json.Unmarshal([]byte(mediaInfo), &info) == nil { + metadataAnimated = info.Animated + metadataWidth = info.Width + metadataHeight = info.Height + } + } + if thumbInfo := data.ContentMetadata["MEDIA_THUMB_INFO"]; thumbInfo != "" { + var info struct { + Width int `json:"width"` + Height int `json:"height"` + } + if json.Unmarshal([]byte(thumbInfo), &info) == nil { + metadataWidth = info.Width + metadataHeight = info.Height + } + } + mediaCategory := lineMediaCategory(data.ContentMetadata) downloadOptions := lineOBSDownloadOptions(data.ContentMetadata, isPlainMedia) + downloadImage := func(c *line.Client) ([]byte, error) { + sid := "emi" + if isPlainMedia { + sid = "m" + } + if metadataAnimated { + originalOptions := downloadOptions + originalOptions.TID = "original" + standardOptions := downloadOptions + if isPlainMedia { + standardOptions.TID = "" + } + + if isPlainMedia { + imgData, err := c.DownloadOBSWithSIDOptions(ctx, oid, data.ID, sid, originalOptions) + if err == nil { + return imgData, nil + } + h.Log.Debug(). + Err(err). + Str("oid", oid). + Str("msg_id", data.ID). + Str("sid", sid). + Msg("Failed to download animated image original, falling back to standard OBS path") + return c.DownloadOBSWithSIDOptions(ctx, oid, data.ID, sid, standardOptions) + } + + imgData, err := c.DownloadOBSWithSIDOptions(ctx, oid, data.ID, sid, standardOptions) + if err == nil { + return imgData, nil + } + h.Log.Debug(). + Err(err). + Str("oid", oid). + Str("msg_id", data.ID). + Str("sid", sid). + Msg("Failed to download encrypted animated image, falling back to original OBS path") + return c.DownloadOBSWithSIDOptions(ctx, oid, data.ID, sid, originalOptions) + } + return c.DownloadOBSWithSIDOptions(ctx, oid, data.ID, sid, downloadOptions) + } + var imgData []byte var err error dlStart := time.Now() @@ -42,20 +112,12 @@ func (h *Handler) ConvertImage(ctx context.Context, portal *bridgev2.Portal, int Bool("has_obs_pop", downloadOptions.OBSPop != ""). Bool("plain_media", isPlainMedia). Msg("Downloading image from LINE OBS") - if isPlainMedia { - imgData, err = client.DownloadOBSWithSIDOptions(ctx, oid, data.ID, "m", downloadOptions) - } else { - imgData, err = client.DownloadOBSWithOptions(ctx, oid, data.ID, downloadOptions) - } + imgData, err = downloadImage(client) // Refresh token if we get a 401 if newClient, ok := h.tryRecoverClient(ctx, err); ok { client = newClient - if isPlainMedia { - imgData, err = client.DownloadOBSWithSIDOptions(ctx, oid, data.ID, "m", downloadOptions) - } else { - imgData, err = client.DownloadOBSWithOptions(ctx, oid, data.ID, downloadOptions) - } + imgData, err = downloadImage(client) } downloadDuration := time.Since(dlStart) @@ -104,9 +166,29 @@ func (h *Handler) ConvertImage(ctx context.Context, portal *bridgev2.Portal, int } } + fileName := "image.jpg" + mimeType := "image/jpeg" + isAnimated := false + + if h.IsAnimatedGif != nil && h.IsAnimatedGif(imgData) { + fileName = "image.gif" + mimeType = "image/gif" + isAnimated = true + } else if len(imgData) >= 3 && string(imgData[0:3]) == "GIF" { + fileName = "image.gif" + mimeType = "image/gif" + isAnimated = metadataAnimated + } else if len(imgData) >= 8 && string(imgData[:8]) == "\x89PNG\r\n\x1a\n" { + fileName = "image.png" + mimeType = "image/png" + } else if len(imgData) >= 12 && string(imgData[:4]) == "RIFF" && string(imgData[8:12]) == "WEBP" { + fileName = "image.webp" + mimeType = "image/webp" + } + // Upload to Matrix uploadStart := time.Now() - mxc, file, err := intent.UploadMedia(ctx, portal.MXID, imgData, "image.jpg", "image/jpeg") + mxc, file, err := intent.UploadMedia(ctx, portal.MXID, imgData, fileName, mimeType) uploadDuration := time.Since(uploadStart) if err != nil { h.Log.Error(). @@ -119,6 +201,25 @@ func (h *Handler) ConvertImage(ctx context.Context, portal *bridgev2.Portal, int return nil, fmt.Errorf("failed to upload image to matrix: %w", err) } + msgType := event.MsgImage + info := &event.FileInfo{ + MimeType: mimeType, + Size: len(imgData), + } + if metadataWidth > 0 && metadataHeight > 0 { + info.Width = metadataWidth + info.Height = metadataHeight + } else if config, _, err := image.DecodeConfig(bytes.NewReader(imgData)); err != nil { + h.Log.Warn().Err(err).Bool("animated", isAnimated).Msg("Failed to decode image dimensions") + } else { + info.Width = config.Width + info.Height = config.Height + } + if isAnimated { + info.MauGIF = true + info.IsAnimated = true + } + matrixMediaURL := string(mxc) if file != nil && file.URL != "" { matrixMediaURL = string(file.URL) @@ -137,10 +238,11 @@ func (h *Handler) ConvertImage(ctx context.Context, portal *bridgev2.Portal, int { Type: event.EventMessage, Content: &event.MessageEventContent{ - MsgType: event.MsgImage, - Body: "image.jpg", + MsgType: msgType, + Body: fileName, URL: mxc, File: file, + Info: info, RelatesTo: relatesTo, }, }, diff --git a/pkg/connector/handlers/video.go b/pkg/connector/handlers/video.go index 8dbf05c..e16144c 100644 --- a/pkg/connector/handlers/video.go +++ b/pkg/connector/handlers/video.go @@ -166,6 +166,16 @@ func (h *Handler) ConvertVideo(ctx context.Context, portal *bridgev2.Portal, int if duration > 0 { videoInfo.Duration = duration } + if thumbInfo := data.ContentMetadata["MEDIA_THUMB_INFO"]; thumbInfo != "" { + var info struct { + Width int `json:"width"` + Height int `json:"height"` + } + if json.Unmarshal([]byte(thumbInfo), &info) == nil && info.Width > 0 && info.Height > 0 { + videoInfo.Width = info.Width + videoInfo.Height = info.Height + } + } return &bridgev2.ConvertedMessage{ Parts: []*bridgev2.ConvertedMessagePart{ diff --git a/pkg/connector/media.go b/pkg/connector/media.go index 5f2cecd..5684a1e 100644 --- a/pkg/connector/media.go +++ b/pkg/connector/media.go @@ -251,6 +251,46 @@ func isAnimatedGif(data []byte) bool { return false } +// convertVideoToGIF converts video data (mp4/webm) to an animated GIF using ffmpeg. +func convertVideoToGIF(videoData []byte) ([]byte, error) { + tmpVideoFile, err := os.CreateTemp("", "video-*.mp4") + if err != nil { + return nil, fmt.Errorf("failed to create temp video file: %w", err) + } + defer os.Remove(tmpVideoFile.Name()) + + if _, err := tmpVideoFile.Write(videoData); err != nil { + tmpVideoFile.Close() + return nil, fmt.Errorf("failed to write video data: %w", err) + } + tmpVideoFile.Close() + + tmpGIFFile, err := os.CreateTemp("", "gif-*.gif") + if err != nil { + return nil, fmt.Errorf("failed to create temp GIF file: %w", err) + } + defer os.Remove(tmpGIFFile.Name()) + tmpGIFFile.Close() + + err = ffmpeg.Input(tmpVideoFile.Name()). + Output(tmpGIFFile.Name(), ffmpeg.KwArgs{ + "vf": "fps=15,scale=320:-1:flags=lanczos", + }). + OverWriteOutput(). + Silent(true). + Run() + if err != nil { + return nil, fmt.Errorf("ffmpeg video-to-gif failed: %w", err) + } + + gifData, err := os.ReadFile(tmpGIFFile.Name()) + if err != nil { + return nil, fmt.Errorf("failed to read GIF output: %w", err) + } + + return gifData, nil +} + // generates the first frame of a video and resizes it to fit within 384x384 func extractVideoThumbnail(videoData []byte) ([]byte, int, int, error) { tmpVideoFile, err := os.CreateTemp("", "video-*.mp4") diff --git a/pkg/connector/send_message.go b/pkg/connector/send_message.go index ec42b8e..40d5d25 100644 --- a/pkg/connector/send_message.go +++ b/pkg/connector/send_message.go @@ -110,11 +110,9 @@ func (lc *LineClient) HandleMatrixMessage(ctx context.Context, msg *bridgev2.Mat // For plain media: we send the message first, then upload media to r/talk/m/{msgId}. var plainMediaData []byte // raw media data to upload after sending - var plainThumbData []byte // raw thumbnail data to upload after sending // For group chats, save original data in case E2EE encryption fails and we fall back to plain. var originalMediaData []byte - var originalThumbData []byte // For file uploads, save the raw data so we can retry with ZIP wrapping if LINE rejects // the raw file (some file types require ZIP, others like PDF work directly). @@ -139,7 +137,10 @@ func (lc *LineClient) HandleMatrixMessage(ctx context.Context, msg *bridgev2.Mat return nil, fmt.Errorf("failed to download media from matrix: %w", err) } - mimeType := msg.Content.Info.MimeType + mimeType := "" + if msg.Content.Info != nil { + mimeType = msg.Content.Info.MimeType + } isGif := mimeType == "image/gif" isAnimated := isGif && isAnimatedGif(data) @@ -156,11 +157,10 @@ func (lc *LineClient) HandleMatrixMessage(ctx context.Context, msg *bridgev2.Mat // Plain media: save data for post-send upload to r/talk/m/{msgId} plainMediaData = data - thumbnailData, thumbWidth, thumbHeight, err := generateThumbnail(data) + _, thumbWidth, thumbHeight, err := generateThumbnail(data) if err != nil { lc.UserLogin.Bridge.Log.Warn().Err(err).Msg("Failed to generate thumbnail, continuing without it") } else { - plainThumbData = thumbnailData mediaThumbInfo := map[string]interface{}{ "width": thumbWidth, "height": thumbHeight, @@ -195,9 +195,6 @@ func (lc *LineClient) HandleMatrixMessage(ctx context.Context, msg *bridgev2.Mat // Save original data for potential group E2EE fallback if isGroup { originalMediaData = data - if thumbData, _, _, tErr := generateThumbnail(data); tErr == nil { - originalThumbData = thumbData - } } uploadData, keyMaterialB64, err := lc.encryptFileData(data) @@ -334,11 +331,110 @@ func (lc *LineClient) HandleMatrixMessage(ctx context.Context, msg *bridgev2.Mat return nil, fmt.Errorf("failed to download video from matrix: %w", err) } + // Beeper sends GIFs as MsgVideo with fi.mau.gif=true — treat as animated image + if msg.Content.Info != nil && msg.Content.Info.MauGIF { + // Convert video (mp4/webm) to actual GIF format for LINE + if !isAnimatedGif(data) { + gifData, convErr := convertVideoToGIF(data) + if convErr != nil { + return nil, fmt.Errorf("failed to convert video to GIF: %w", convErr) + } + data = gifData + lc.UserLogin.Bridge.Log.Info(). + Int("gif_size", len(data)). + Msg("Converted video to GIF for LINE") + } + + contentType = int(ContentImage) + + fileName := msg.Content.GetFileName() + if fileName == "" { + fileName = "image.gif" + } + contentMetadata["FILE_NAME"] = fileName + contentMetadata["FILE_SIZE"] = fmt.Sprintf("%d", len(data)) + contentMetadata["contentType"] = fmt.Sprintf("%d", ContentImage) + + setGifMediaInfo := func(fileSize int) { + mediaContentInfo := map[string]interface{}{ + "category": "original", + "fileSize": fileSize, + "extension": "gif", + "animated": true, + } + if mediaInfoJSON, err := json.Marshal(mediaContentInfo); err == nil { + contentMetadata["MEDIA_CONTENT_INFO"] = string(mediaInfoJSON) + } + } + setGifMediaInfo(len(data)) + + if plainText { + plainMediaData = data + + _, thumbWidth, thumbHeight, err := generateThumbnail(data) + if err != nil { + lc.UserLogin.Bridge.Log.Warn().Err(err).Msg("Failed to generate GIF thumbnail dimensions, continuing without it") + } else { + mediaThumbInfo := map[string]interface{}{ + "width": thumbWidth, + "height": thumbHeight, + } + if thumbInfoJSON, err := json.Marshal(mediaThumbInfo); err == nil { + contentMetadata["MEDIA_THUMB_INFO"] = string(thumbInfoJSON) + } + } + } else { + if isGroup { + originalMediaData = data + } + + uploadData, keyMaterialB64, err := lc.encryptFileData(data) + if err != nil { + return nil, fmt.Errorf("failed to encrypt GIF data: %w", err) + } + + oid, err := client.UploadOBS(uploadData) + if err != nil { + return nil, fmt.Errorf("failed to upload GIF to OBS: %w", err) + } + + thumbnailData, thumbWidth, thumbHeight, err := generateThumbnail(data) + if err != nil { + lc.UserLogin.Bridge.Log.Warn().Err(err).Msg("Failed to generate GIF thumbnail, continuing without it") + } else if thumbToUpload, err := encryptThumbnail(thumbnailData, keyMaterialB64); err != nil { + lc.UserLogin.Bridge.Log.Warn().Err(err).Msg("Failed to encrypt GIF thumbnail, continuing without it") + } else { + previewOID := fmt.Sprintf("%s__ud-preview", oid) + if err := client.UploadOBSWithOID(thumbToUpload, previewOID); err != nil { + lc.UserLogin.Bridge.Log.Warn().Err(err).Msg("Failed to upload GIF preview, continuing without it") + } else { + mediaThumbInfo := map[string]interface{}{ + "width": thumbWidth, + "height": thumbHeight, + } + if thumbInfoJSON, err := json.Marshal(mediaThumbInfo); err == nil { + contentMetadata["MEDIA_THUMB_INFO"] = string(thumbInfoJSON) + } + } + } + + contentMetadata["OID"] = oid + contentMetadata["SID"] = "emi" + contentMetadata["FILE_SIZE"] = fmt.Sprintf("%d", len(uploadData)) + contentMetadata["ENC_KM"] = keyMaterialB64 + setGifMediaInfo(len(uploadData)) + + imgPayload := map[string]string{"keyMaterial": keyMaterialB64} + payload, _ = json.Marshal(imgPayload) + } + break + } + contentType = int(ContentVideo) contentMetadata["FILE_SIZE"] = fmt.Sprintf("%d", len(data)) contentMetadata["contentType"] = fmt.Sprintf("%d", ContentVideo) - if msg.Content.Info.Duration > 0 { + if msg.Content.Info != nil && msg.Content.Info.Duration > 0 { contentMetadata["DURATION"] = fmt.Sprintf("%d", msg.Content.Info.Duration) } @@ -357,7 +453,6 @@ func (lc *LineClient) HandleMatrixMessage(ctx context.Context, msg *bridgev2.Mat thumbnailData = thumbBuf.Bytes() } if len(thumbnailData) > 0 { - plainThumbData = thumbnailData mediaThumbInfo := map[string]interface{}{ "width": thumbWidth, "height": thumbHeight, @@ -374,9 +469,6 @@ func (lc *LineClient) HandleMatrixMessage(ctx context.Context, msg *bridgev2.Mat } else { if isGroup { originalMediaData = data - if thumbData, _, _, tErr := extractVideoThumbnail(data); tErr == nil { - originalThumbData = thumbData - } } uploadData, keyMaterialB64, err := lc.encryptVideoData(data) @@ -553,7 +645,6 @@ func (lc *LineClient) HandleMatrixMessage(ctx context.Context, msg *bridgev2.Mat delete(contentMetadata, "SID") delete(contentMetadata, "ENC_KM") plainMediaData = originalMediaData - plainThumbData = originalThumbData } } } @@ -733,7 +824,7 @@ func (lc *LineClient) HandleMatrixMessage(ctx context.Context, msg *bridgev2.Mat obsType = "file" } - if err := client.UploadOBSPlain(plainMediaData, sentMsg.ID, obsType); err != nil { + if err := client.UploadOBSPlain(plainMediaData, sentMsg.ID, obsType, contentMetadata["FILE_NAME"]); err != nil { return nil, fmt.Errorf("failed to upload plain media to OBS: %w", err) } lc.UserLogin.Bridge.Log.Info(). @@ -742,12 +833,8 @@ func (lc *LineClient) HandleMatrixMessage(ctx context.Context, msg *bridgev2.Mat Int("media_size", len(plainMediaData)). Msg("Uploaded plain media after sending") - if plainThumbData != nil { - previewID := fmt.Sprintf("%s__ud-preview", sentMsg.ID) - if err := client.UploadOBSPlain(plainThumbData, previewID, obsType); err != nil { - lc.UserLogin.Bridge.Log.Warn().Err(err).Msg("Failed to upload plain media thumbnail, continuing without it") - } - } + // Skip plain thumbnail upload — LINE generates thumbnails server-side + // for media uploaded to the r/talk/m/ endpoint. } return &bridgev2.MatrixMessageResponse{ diff --git a/pkg/connector/userinfo.go b/pkg/connector/userinfo.go index a6d4388..9008a4e 100644 --- a/pkg/connector/userinfo.go +++ b/pkg/connector/userinfo.go @@ -128,6 +128,14 @@ func (lc *LineClient) GetCapabilities(ctx context.Context, portal *bridgev2.Port "audio/3gpp": event.CapLevelFullySupported, }, }, + event.CapMsgGIF: { + Caption: event.CapLevelRejected, + MimeTypes: map[string]event.CapabilitySupportLevel{ + "image/gif": event.CapLevelFullySupported, + "video/mp4": event.CapLevelFullySupported, + "video/webm": event.CapLevelFullySupported, + }, + }, }, } } diff --git a/pkg/line/client.go b/pkg/line/client.go index d65f06e..b966d2b 100644 --- a/pkg/line/client.go +++ b/pkg/line/client.go @@ -508,7 +508,8 @@ func obsTypeFromSID(sid string) string { // UploadOBSPlain uploads plain (non-E2EE) media to a specific OID via the "m" endpoint. // obsType should be "image", "video", "audio", or "file". -func (c *Client) UploadOBSPlain(data []byte, oid string, obsType string) error { +// fileName is used in the OBS params name field (falls back to timestamp if empty). +func (c *Client) UploadOBSPlain(data []byte, oid string, obsType string, fileName string) error { obsToken, err := c.AcquireEncryptedAccessToken() if err != nil { return fmt.Errorf("failed to acquire OBS token: %w", err) @@ -521,10 +522,15 @@ func (c *Client) UploadOBSPlain(data []byte, oid string, obsType string) error { return fmt.Errorf("failed to create OBS request: %w", err) } + if fileName == "" { + fileName = fmt.Sprintf("%d", time.Now().UnixMilli()) + } + obsParams := map[string]string{ "ver": "2.0", - "name": fmt.Sprintf("%d", time.Now().UnixMilli()), + "name": fileName, "type": obsType, + "cat": "original", } obsParamsJSON, _ := json.Marshal(obsParams) obsParamsB64 := base64.StdEncoding.EncodeToString(obsParamsJSON) @@ -532,7 +538,7 @@ func (c *Client) UploadOBSPlain(data []byte, oid string, obsType string) error { req.Header.Set("User-Agent", UserAgent) req.Header.Set("x-line-application", "CHROMEOS\t3.7.2\tChrome_OS") req.Header.Set("x-lal", "en_US") - req.Header.Set("Content-Type", "application/octet-stream") + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("X-Obs-Params", obsParamsB64) req.Header.Set("x-line-access", obsToken) @@ -684,6 +690,11 @@ func (c *Client) DownloadOBSWithSID(ctx context.Context, oid string, messageID s return c.DownloadOBSWithSIDOptions(ctx, oid, messageID, sid, OBSDownloadOptions{}) } +// DownloadOBSOriginal downloads the original quality media instead of LINE's preview. +func (c *Client) DownloadOBSOriginal(ctx context.Context, oid string, messageID string, sid string) ([]byte, error) { + return c.DownloadOBSWithSIDOptions(ctx, oid, messageID, sid, OBSDownloadOptions{TID: "original"}) +} + func (c *Client) DownloadOBSWithSIDOptions(ctx context.Context, oid string, messageID string, sid string, opts OBSDownloadOptions) ([]byte, error) { // URL structure: https://obs.line-apps.com/r/talk/{SID}/{OID} // SID: emi (images), emv (videos), ema (audio), emf (files) diff --git a/pkg/line/methods.go b/pkg/line/methods.go index b976573..d4dcf76 100644 --- a/pkg/line/methods.go +++ b/pkg/line/methods.go @@ -11,13 +11,17 @@ import ( ) var ( - obsTokenMu sync.Mutex - obsTokenCache string - obsTokenExpiry time.Time + obsTokenMu sync.Mutex + obsTokenCache = make(map[string]obsTokenCacheEntry) ) const obsTokenBuffer = 30 * time.Second +type obsTokenCacheEntry struct { + token string + expiry time.Time +} + // InvalidateOBSTokenCache clears the cached OBS access token. The OBS token is // derived from the main LINE access token; when the latter is rotated (refresh // or re-login) any previously-issued OBS token is invalidated server-side, but @@ -25,8 +29,7 @@ const obsTokenBuffer = 30 * time.Second // Callers must invoke this after any successful re-authentication. func InvalidateOBSTokenCache() { obsTokenMu.Lock() - obsTokenCache = "" - obsTokenExpiry = time.Time{} + obsTokenCache = make(map[string]obsTokenCacheEntry) obsTokenMu.Unlock() } @@ -638,11 +641,11 @@ func (c *Client) GetLastOpRevision() (int64, error) { // this token is used to encrypt images, videos, and files uploaded to LINE's OBS storage func (c *Client) AcquireEncryptedAccessToken() (string, error) { + cacheKey := c.AccessToken obsTokenMu.Lock() - if obsTokenCache != "" && time.Now().Before(obsTokenExpiry) { - cached := obsTokenCache + if cached, ok := obsTokenCache[cacheKey]; ok && cached.token != "" && time.Now().Before(cached.expiry) { obsTokenMu.Unlock() - return cached, nil + return cached.token, nil } obsTokenMu.Unlock() @@ -674,8 +677,10 @@ func (c *Client) AcquireEncryptedAccessToken() (string, error) { token := parts[1] if expirySec, err := strconv.Atoi(parts[0]); err == nil && expirySec > 0 { obsTokenMu.Lock() - obsTokenCache = token - obsTokenExpiry = time.Now().Add(time.Duration(expirySec)*time.Second - obsTokenBuffer) + obsTokenCache[cacheKey] = obsTokenCacheEntry{ + token: token, + expiry: time.Now().Add(time.Duration(expirySec)*time.Second - obsTokenBuffer), + } obsTokenMu.Unlock() }