mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-06-12 18:08:54 +00:00
bacb9aba7c
Always route through classifySDKError to ensure resp.Body is closed even when the API call succeeds. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
627 lines
18 KiB
Go
627 lines
18 KiB
Go
package line
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/line/line-bot-sdk-go/v8/linebot/messaging_api"
|
|
"github.com/line/line-bot-sdk-go/v8/linebot/webhook"
|
|
|
|
"github.com/sipeed/picoclaw/pkg/bus"
|
|
"github.com/sipeed/picoclaw/pkg/channels"
|
|
"github.com/sipeed/picoclaw/pkg/config"
|
|
"github.com/sipeed/picoclaw/pkg/identity"
|
|
"github.com/sipeed/picoclaw/pkg/logger"
|
|
"github.com/sipeed/picoclaw/pkg/media"
|
|
"github.com/sipeed/picoclaw/pkg/utils"
|
|
)
|
|
|
|
const (
|
|
lineContentEndpoint = "https://api-data.line.me/v2/bot/message/%s/content"
|
|
lineReplyTokenMaxAge = 25 * time.Second
|
|
|
|
// Limit request body to prevent memory exhaustion (DoS).
|
|
// LINE webhook payloads are typically a few KB; 1 MiB is generous.
|
|
maxWebhookBodySize = 1 << 20 // 1 MiB
|
|
)
|
|
|
|
type replyTokenEntry struct {
|
|
token string
|
|
timestamp time.Time
|
|
}
|
|
|
|
// LINEChannel implements the Channel interface for LINE Official Account
|
|
// using the LINE Messaging API with HTTP webhook for receiving messages
|
|
// and the official LINE Bot SDK for sending messages.
|
|
type LINEChannel struct {
|
|
*channels.BaseChannel
|
|
config *config.LINESettings
|
|
client *messaging_api.MessagingApiAPI
|
|
botUserID string // Bot's user ID
|
|
botBasicID string // Bot's basic ID (e.g. @216ru...)
|
|
botDisplayName string // Bot's display name for text-based mention detection
|
|
replyTokens sync.Map // chatID -> replyTokenEntry
|
|
quoteTokens sync.Map // chatID -> quoteToken (string)
|
|
ctx context.Context
|
|
cancel context.CancelFunc
|
|
}
|
|
|
|
// NewLINEChannel creates a new LINE channel instance.
|
|
func NewLINEChannel(
|
|
bc *config.Channel,
|
|
cfg *config.LINESettings,
|
|
messageBus *bus.MessageBus,
|
|
) (*LINEChannel, error) {
|
|
if cfg.ChannelSecret.String() == "" || cfg.ChannelAccessToken.String() == "" {
|
|
return nil, fmt.Errorf("line channel_secret and channel_access_token are required")
|
|
}
|
|
|
|
client, err := messaging_api.NewMessagingApiAPI(
|
|
cfg.ChannelAccessToken.String(),
|
|
messaging_api.WithHTTPClient(&http.Client{Timeout: 30 * time.Second}),
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create LINE messaging client: %w", err)
|
|
}
|
|
|
|
base := channels.NewBaseChannel("line", cfg, messageBus, bc.AllowFrom,
|
|
channels.WithMaxMessageLength(5000),
|
|
channels.WithGroupTrigger(bc.GroupTrigger),
|
|
channels.WithReasoningChannelID(bc.ReasoningChannelID),
|
|
)
|
|
|
|
return &LINEChannel{
|
|
BaseChannel: base,
|
|
config: cfg,
|
|
client: client,
|
|
}, nil
|
|
}
|
|
|
|
// Start initializes the LINE channel.
|
|
func (c *LINEChannel) Start(ctx context.Context) error {
|
|
logger.InfoC("line", "Starting LINE channel (Webhook Mode)")
|
|
|
|
c.ctx, c.cancel = context.WithCancel(ctx)
|
|
|
|
// Fetch bot profile to get bot's userId for mention detection
|
|
info, err := c.client.WithContext(ctx).GetBotInfo()
|
|
if err != nil {
|
|
logger.WarnCF("line", "Failed to fetch bot info (mention detection disabled)", map[string]any{
|
|
"error": err.Error(),
|
|
})
|
|
} else {
|
|
c.botUserID = info.UserId
|
|
c.botBasicID = info.BasicId
|
|
c.botDisplayName = info.DisplayName
|
|
logger.InfoCF("line", "Bot info fetched", map[string]any{
|
|
"bot_user_id": c.botUserID,
|
|
"basic_id": c.botBasicID,
|
|
"display_name": c.botDisplayName,
|
|
})
|
|
}
|
|
|
|
c.SetRunning(true)
|
|
logger.InfoC("line", "LINE channel started (Webhook Mode)")
|
|
return nil
|
|
}
|
|
|
|
// Stop gracefully stops the LINE channel.
|
|
func (c *LINEChannel) Stop(ctx context.Context) error {
|
|
logger.InfoC("line", "Stopping LINE channel")
|
|
|
|
if c.cancel != nil {
|
|
c.cancel()
|
|
}
|
|
|
|
c.SetRunning(false)
|
|
logger.InfoC("line", "LINE channel stopped")
|
|
return nil
|
|
}
|
|
|
|
// WebhookPath returns the path for registering on the shared HTTP server.
|
|
func (c *LINEChannel) WebhookPath() string {
|
|
if c.config.WebhookPath != "" {
|
|
return c.config.WebhookPath
|
|
}
|
|
return "/webhook/line"
|
|
}
|
|
|
|
// ServeHTTP implements http.Handler for the shared HTTP server.
|
|
func (c *LINEChannel) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|
c.webhookHandler(w, r)
|
|
}
|
|
|
|
// webhookHandler handles incoming LINE webhook requests.
|
|
func (c *LINEChannel) webhookHandler(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
// Limit body size to prevent memory exhaustion (DoS).
|
|
// ParseRequest reads r.Body internally via io.ReadAll; wrapping with
|
|
// MaxBytesReader ensures oversized payloads are rejected before full
|
|
// allocation.
|
|
r.Body = http.MaxBytesReader(w, r.Body, maxWebhookBodySize)
|
|
|
|
cb, err := webhook.ParseRequest(c.config.ChannelSecret.String(), r)
|
|
if err != nil {
|
|
var maxBytesErr *http.MaxBytesError
|
|
if errors.As(err, &maxBytesErr) {
|
|
logger.WarnC("line", "Webhook request body too large, rejected")
|
|
http.Error(w, "Request entity too large", http.StatusRequestEntityTooLarge)
|
|
} else if errors.Is(err, webhook.ErrInvalidSignature) {
|
|
logger.WarnC("line", "Invalid webhook signature")
|
|
http.Error(w, "Forbidden", http.StatusForbidden)
|
|
} else {
|
|
logger.ErrorCF("line", "Failed to parse webhook request", map[string]any{
|
|
"error": err.Error(),
|
|
})
|
|
http.Error(w, "Bad request", http.StatusBadRequest)
|
|
}
|
|
return
|
|
}
|
|
|
|
// Return 200 immediately, process events asynchronously
|
|
w.WriteHeader(http.StatusOK)
|
|
|
|
for _, event := range cb.Events {
|
|
go c.processEvent(event)
|
|
}
|
|
}
|
|
|
|
func (c *LINEChannel) processEvent(event webhook.EventInterface) {
|
|
msgEvent, ok := event.(webhook.MessageEvent)
|
|
if !ok {
|
|
logger.DebugCF("line", "Ignoring non-message event", map[string]any{
|
|
"type": event.GetType(),
|
|
})
|
|
return
|
|
}
|
|
|
|
senderID, chatID, sourceType := c.resolveSource(msgEvent.Source)
|
|
isGroup := sourceType == "group" || sourceType == "room"
|
|
|
|
// Store reply token for later use
|
|
if msgEvent.ReplyToken != "" {
|
|
c.replyTokens.Store(chatID, replyTokenEntry{
|
|
token: msgEvent.ReplyToken,
|
|
timestamp: time.Now(),
|
|
})
|
|
}
|
|
|
|
var content string
|
|
var mediaPaths []string
|
|
var messageID string
|
|
var quoteToken string
|
|
var isMentioned bool
|
|
|
|
// Helper to register a local file with the media store
|
|
storeMedia := func(localPath, filename, scope string) string {
|
|
if store := c.GetMediaStore(); store != nil {
|
|
ref, err := store.Store(localPath, media.MediaMeta{
|
|
Filename: filename,
|
|
Source: "line",
|
|
}, scope)
|
|
if err == nil {
|
|
return ref
|
|
}
|
|
}
|
|
return localPath // fallback
|
|
}
|
|
|
|
switch msg := msgEvent.Message.(type) {
|
|
case webhook.TextMessageContent:
|
|
messageID = msg.Id
|
|
content = msg.Text
|
|
isMentioned = c.isBotMentioned(msg)
|
|
// Store quote token for quoting the original message in reply
|
|
if msg.QuoteToken != "" {
|
|
quoteToken = msg.QuoteToken
|
|
c.quoteTokens.Store(chatID, msg.QuoteToken)
|
|
}
|
|
// Strip bot mention from text in group chats
|
|
if isGroup {
|
|
content = c.stripBotMention(content, msg)
|
|
}
|
|
case webhook.ImageMessageContent:
|
|
messageID = msg.Id
|
|
if msg.QuoteToken != "" {
|
|
quoteToken = msg.QuoteToken
|
|
c.quoteTokens.Store(chatID, msg.QuoteToken)
|
|
}
|
|
if localPath := c.downloadContent(msg.Id, "image.jpg"); localPath != "" {
|
|
scope := channels.BuildMediaScope("line", chatID, msg.Id)
|
|
mediaPaths = append(mediaPaths, storeMedia(localPath, "image.jpg", scope))
|
|
content = "[image]"
|
|
}
|
|
case webhook.AudioMessageContent:
|
|
messageID = msg.Id
|
|
if localPath := c.downloadContent(msg.Id, "audio.m4a"); localPath != "" {
|
|
scope := channels.BuildMediaScope("line", chatID, msg.Id)
|
|
mediaPaths = append(mediaPaths, storeMedia(localPath, "audio.m4a", scope))
|
|
content = "[audio]"
|
|
}
|
|
case webhook.VideoMessageContent:
|
|
messageID = msg.Id
|
|
if msg.QuoteToken != "" {
|
|
quoteToken = msg.QuoteToken
|
|
c.quoteTokens.Store(chatID, msg.QuoteToken)
|
|
}
|
|
if localPath := c.downloadContent(msg.Id, "video.mp4"); localPath != "" {
|
|
scope := channels.BuildMediaScope("line", chatID, msg.Id)
|
|
mediaPaths = append(mediaPaths, storeMedia(localPath, "video.mp4", scope))
|
|
content = "[video]"
|
|
}
|
|
case webhook.FileMessageContent:
|
|
messageID = msg.Id
|
|
content = "[file]"
|
|
case webhook.LocationMessageContent:
|
|
messageID = msg.Id
|
|
content = "[location]"
|
|
if msg.Title != "" {
|
|
content = fmt.Sprintf("[location: %s]", msg.Title)
|
|
}
|
|
case webhook.StickerMessageContent:
|
|
messageID = msg.Id
|
|
if msg.QuoteToken != "" {
|
|
quoteToken = msg.QuoteToken
|
|
c.quoteTokens.Store(chatID, msg.QuoteToken)
|
|
}
|
|
content = "[sticker]"
|
|
default:
|
|
logger.DebugCF("line", "Ignoring unsupported message type", map[string]any{
|
|
"type": msgEvent.Message.GetType(),
|
|
})
|
|
return
|
|
}
|
|
|
|
if strings.TrimSpace(content) == "" {
|
|
return
|
|
}
|
|
|
|
// In group chats, apply unified group trigger filtering
|
|
if isGroup {
|
|
respond, cleaned := c.ShouldRespondInGroup(isMentioned, content)
|
|
if !respond {
|
|
logger.DebugCF("line", "Ignoring group message by group trigger", map[string]any{
|
|
"chat_id": chatID,
|
|
})
|
|
return
|
|
}
|
|
content = cleaned
|
|
}
|
|
|
|
metadata := map[string]string{
|
|
"platform": "line",
|
|
"source_type": sourceType,
|
|
}
|
|
|
|
logger.DebugCF("line", "Received message", map[string]any{
|
|
"sender_id": senderID,
|
|
"chat_id": chatID,
|
|
"message_type": msgEvent.Message.GetType(),
|
|
"is_group": isGroup,
|
|
"preview": utils.Truncate(content, 50),
|
|
})
|
|
|
|
sender := bus.SenderInfo{
|
|
Platform: "line",
|
|
PlatformID: senderID,
|
|
CanonicalID: identity.BuildCanonicalID("line", senderID),
|
|
}
|
|
|
|
if !c.IsAllowedSender(sender) {
|
|
return
|
|
}
|
|
|
|
inboundCtx := bus.InboundContext{
|
|
Channel: c.Name(),
|
|
ChatID: chatID,
|
|
ChatType: map[bool]string{true: "group", false: "direct"}[isGroup],
|
|
SenderID: senderID,
|
|
MessageID: messageID,
|
|
Mentioned: isMentioned,
|
|
Raw: metadata,
|
|
}
|
|
if msgEvent.ReplyToken != "" {
|
|
inboundCtx.ReplyHandles = map[string]string{
|
|
"reply_token": msgEvent.ReplyToken,
|
|
}
|
|
if quoteToken != "" {
|
|
inboundCtx.ReplyHandles["quote_token"] = quoteToken
|
|
}
|
|
}
|
|
|
|
c.HandleInboundContext(c.ctx, chatID, content, mediaPaths, inboundCtx, sender)
|
|
}
|
|
|
|
// isBotMentioned checks if the bot is mentioned in the message.
|
|
// It first checks the mention metadata (userId match or IsSelf), then falls back
|
|
// to text-based detection using the bot's display name, since LINE may
|
|
// not include userId in mentionees for Official Accounts.
|
|
func (c *LINEChannel) isBotMentioned(msg webhook.TextMessageContent) bool {
|
|
if msg.Mention != nil {
|
|
for _, m := range msg.Mention.Mentionees {
|
|
switch mentionee := m.(type) {
|
|
case webhook.AllMentionee:
|
|
return true
|
|
case webhook.UserMentionee:
|
|
if mentionee.IsSelf {
|
|
return true
|
|
}
|
|
if c.botUserID != "" && mentionee.UserId == c.botUserID {
|
|
return true
|
|
}
|
|
// Check if mentionee text overlaps with bot display name
|
|
if c.botDisplayName != "" && mentionee.Index >= 0 && mentionee.Length > 0 {
|
|
runes := []rune(msg.Text)
|
|
end := int(mentionee.Index) + int(mentionee.Length)
|
|
if end <= len(runes) {
|
|
mentionText := string(runes[mentionee.Index:end])
|
|
if strings.Contains(mentionText, c.botDisplayName) {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Fallback: text-based detection with display name
|
|
if c.botDisplayName != "" && strings.Contains(msg.Text, "@"+c.botDisplayName) {
|
|
return true
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// stripBotMention removes the @BotName mention text from the message.
|
|
func (c *LINEChannel) stripBotMention(text string, msg webhook.TextMessageContent) string {
|
|
stripped := false
|
|
|
|
if msg.Mention != nil {
|
|
runes := []rune(text)
|
|
for i := len(msg.Mention.Mentionees) - 1; i >= 0; i-- {
|
|
m := msg.Mention.Mentionees[i]
|
|
shouldStrip := false
|
|
var index, length int32
|
|
|
|
switch mentionee := m.(type) {
|
|
case webhook.UserMentionee:
|
|
index = mentionee.Index
|
|
length = mentionee.Length
|
|
if mentionee.IsSelf {
|
|
shouldStrip = true
|
|
} else if c.botUserID != "" && mentionee.UserId == c.botUserID {
|
|
shouldStrip = true
|
|
} else if c.botDisplayName != "" && index >= 0 && length > 0 {
|
|
end := int(index) + int(length)
|
|
if end <= len(runes) {
|
|
mentionText := string(runes[index:end])
|
|
if strings.Contains(mentionText, c.botDisplayName) {
|
|
shouldStrip = true
|
|
}
|
|
}
|
|
}
|
|
case webhook.AllMentionee:
|
|
// Don't strip @All mentions
|
|
continue
|
|
default:
|
|
continue
|
|
}
|
|
|
|
if shouldStrip {
|
|
start := int(index)
|
|
end := int(index) + int(length)
|
|
if start >= 0 && end <= len(runes) {
|
|
runes = append(runes[:start], runes[end:]...)
|
|
stripped = true
|
|
}
|
|
}
|
|
}
|
|
if stripped {
|
|
return strings.TrimSpace(string(runes))
|
|
}
|
|
}
|
|
|
|
// Fallback: strip @DisplayName from text
|
|
if c.botDisplayName != "" {
|
|
text = strings.ReplaceAll(text, "@"+c.botDisplayName, "")
|
|
}
|
|
|
|
return strings.TrimSpace(text)
|
|
}
|
|
|
|
// resolveSource extracts senderID, chatID, and source type from the event source.
|
|
func (c *LINEChannel) resolveSource(source webhook.SourceInterface) (senderID, chatID, sourceType string) {
|
|
switch src := source.(type) {
|
|
case webhook.GroupSource:
|
|
return src.UserId, src.GroupId, "group"
|
|
case webhook.RoomSource:
|
|
return src.UserId, src.RoomId, "room"
|
|
case webhook.UserSource:
|
|
return src.UserId, src.UserId, "user"
|
|
default:
|
|
logger.WarnCF("line", "Unknown source type", map[string]any{
|
|
"type": fmt.Sprintf("%T", source),
|
|
})
|
|
return "", "", "unknown"
|
|
}
|
|
}
|
|
|
|
// Send sends a message to LINE. It first tries the Reply API (free)
|
|
// using a cached reply token, then falls back to the Push API.
|
|
func (c *LINEChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) {
|
|
if !c.IsRunning() {
|
|
return nil, channels.ErrNotRunning
|
|
}
|
|
|
|
// Load and consume quote token for this chat
|
|
var quoteToken string
|
|
if qt, ok := c.quoteTokens.LoadAndDelete(msg.ChatID); ok {
|
|
quoteToken = qt.(string)
|
|
}
|
|
|
|
textMsg := messaging_api.TextMessage{
|
|
Text: msg.Content,
|
|
QuoteToken: quoteToken,
|
|
}
|
|
|
|
// Try reply token first (free, valid for ~25 seconds)
|
|
if entry, ok := c.replyTokens.LoadAndDelete(msg.ChatID); ok {
|
|
tokenEntry := entry.(replyTokenEntry)
|
|
if time.Since(tokenEntry.timestamp) < lineReplyTokenMaxAge {
|
|
resp, _, err := c.client.WithContext(ctx).ReplyMessageWithHttpInfo(&messaging_api.ReplyMessageRequest{
|
|
ReplyToken: tokenEntry.token,
|
|
Messages: []messaging_api.MessageInterface{&textMsg},
|
|
})
|
|
if resp != nil && resp.Body != nil {
|
|
resp.Body.Close()
|
|
}
|
|
if err == nil {
|
|
logger.DebugCF("line", "Message sent via Reply API", map[string]any{
|
|
"chat_id": msg.ChatID,
|
|
"quoted": quoteToken != "",
|
|
})
|
|
return nil, nil
|
|
}
|
|
logger.DebugCF("line", "Reply API failed, falling back to Push API", map[string]any{
|
|
"error": err.Error(),
|
|
})
|
|
}
|
|
}
|
|
|
|
// Fall back to Push API
|
|
resp, _, err := c.client.WithContext(ctx).PushMessageWithHttpInfo(&messaging_api.PushMessageRequest{
|
|
To: msg.ChatID,
|
|
Messages: []messaging_api.MessageInterface{&textMsg},
|
|
}, "")
|
|
return nil, classifySDKError(resp, err)
|
|
}
|
|
|
|
// SendMedia implements the channels.MediaSender interface.
|
|
// LINE requires media to be accessible via public URL; since we only have local files,
|
|
// we fall back to sending a text message with the filename/caption.
|
|
// For full support, an external file hosting service would be needed.
|
|
func (c *LINEChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) ([]string, error) {
|
|
if !c.IsRunning() {
|
|
return nil, channels.ErrNotRunning
|
|
}
|
|
|
|
store := c.GetMediaStore()
|
|
if store == nil {
|
|
return nil, fmt.Errorf("no media store available: %w", channels.ErrSendFailed)
|
|
}
|
|
|
|
// LINE Messaging API requires publicly accessible URLs for media messages.
|
|
// Since we only have local file paths, send caption text as fallback.
|
|
for _, part := range msg.Parts {
|
|
caption := part.Caption
|
|
if caption == "" {
|
|
caption = fmt.Sprintf("[%s: %s]", part.Type, part.Filename)
|
|
}
|
|
|
|
textMsg := messaging_api.TextMessage{Text: caption}
|
|
resp, _, err := c.client.WithContext(ctx).PushMessageWithHttpInfo(&messaging_api.PushMessageRequest{
|
|
To: msg.ChatID,
|
|
Messages: []messaging_api.MessageInterface{&textMsg},
|
|
}, "")
|
|
if sdkErr := classifySDKError(resp, err); sdkErr != nil {
|
|
return nil, sdkErr
|
|
}
|
|
}
|
|
|
|
return nil, nil
|
|
}
|
|
|
|
// StartTyping implements channels.TypingCapable using LINE's loading animation.
|
|
//
|
|
// NOTE: The LINE loading animation API only works for 1:1 chats.
|
|
// Group/room chat IDs (starting with "C" or "R") are detected automatically;
|
|
// for these, a no-op stop function is returned without calling the API.
|
|
func (c *LINEChannel) StartTyping(ctx context.Context, chatID string) (func(), error) {
|
|
if chatID == "" {
|
|
return func() {}, nil
|
|
}
|
|
|
|
// Group/room chats: LINE loading animation is 1:1 only.
|
|
if strings.HasPrefix(chatID, "C") || strings.HasPrefix(chatID, "R") {
|
|
return func() {}, nil
|
|
}
|
|
|
|
typingCtx, cancel := context.WithCancel(ctx)
|
|
var once sync.Once
|
|
stop := func() { once.Do(cancel) }
|
|
|
|
// Send immediately, then refresh periodically for long-running tasks.
|
|
if err := c.sendLoading(typingCtx, chatID); err != nil {
|
|
stop()
|
|
return stop, err
|
|
}
|
|
|
|
ticker := time.NewTicker(50 * time.Second)
|
|
go func() {
|
|
defer ticker.Stop()
|
|
for {
|
|
select {
|
|
case <-typingCtx.Done():
|
|
return
|
|
case <-ticker.C:
|
|
if err := c.sendLoading(typingCtx, chatID); err != nil {
|
|
logger.DebugCF("line", "Failed to refresh loading indicator", map[string]any{
|
|
"error": err.Error(),
|
|
})
|
|
}
|
|
}
|
|
}
|
|
}()
|
|
|
|
return stop, nil
|
|
}
|
|
|
|
// classifySDKError maps an SDK HTTP response to the project's sentinel errors.
|
|
func classifySDKError(resp *http.Response, err error) error {
|
|
if resp != nil && resp.Body != nil {
|
|
resp.Body.Close()
|
|
}
|
|
if err == nil {
|
|
return nil
|
|
}
|
|
if resp != nil {
|
|
return channels.ClassifySendError(resp.StatusCode, err)
|
|
}
|
|
return channels.ClassifyNetError(err)
|
|
}
|
|
|
|
// sendLoading sends a loading animation indicator to the chat.
|
|
func (c *LINEChannel) sendLoading(ctx context.Context, chatID string) error {
|
|
req := &messaging_api.ShowLoadingAnimationRequest{
|
|
ChatId: chatID,
|
|
LoadingSeconds: 60,
|
|
}
|
|
resp, _, err := c.client.WithContext(ctx).ShowLoadingAnimationWithHttpInfo(req)
|
|
return classifySDKError(resp, err)
|
|
}
|
|
|
|
// downloadContent downloads media content from the LINE content API.
|
|
func (c *LINEChannel) downloadContent(messageID, filename string) string {
|
|
url := fmt.Sprintf(lineContentEndpoint, messageID)
|
|
return utils.DownloadFile(url, filename, utils.DownloadOptions{
|
|
LoggerPrefix: "line",
|
|
ExtraHeaders: map[string]string{
|
|
"Authorization": "Bearer " + c.config.ChannelAccessToken.String(),
|
|
},
|
|
})
|
|
}
|
|
|
|
// VoiceCapabilities returns the voice capabilities of the channel.
|
|
func (c *LINEChannel) VoiceCapabilities() channels.VoiceCapabilities {
|
|
return channels.VoiceCapabilities{ASR: true, TTS: true}
|
|
}
|