Files
cc-connect/platform/weixin/weixin.go
T
ClaudeandCursor eb86866420 fix: three bug fixes for #1184, #1139, and #1176
fix(core): multiSelect AskUserQuestion on card platforms (closes #1184)
- For multiSelect questions, render options as a numbered text list
  instead of instant-resolve buttons (buttons resolved on first click,
  preventing multi-selection)
- Add new i18n key MsgAskQuestionNoteMulti with per-language hint to
  reply with comma-separated numbers (e.g. 1,3)
- Single-select questions keep the existing button UX unchanged

fix(config): allow cc-connect web with agent-only config (closes #1139)
- Add LoadPermissive() / validatePermissive() that skip the
  "at least one platform" requirement
- runWeb now uses LoadPermissive so the web admin UI starts even
  before any platforms are configured, enabling first-time setup

fix(weixin): fail fast on ret=-2 when context_token cannot be refreshed (closes #1176)
- When sendMessage returns ret=-2 and the stored token is the same as
  the current one (no inbound message has refreshed it), stop retrying
  immediately instead of burning 3 retries on the same stale token
- Return a clear actionable error: "user must send a new message to
  refresh the session token"
- Applied to both text (weixin.go) and media (media_outbound.go) paths

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-04 17:01:01 +08:00

765 lines
21 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package weixin
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"sync"
"time"
"unicode/utf8"
"github.com/chenhg5/cc-connect/core"
)
func init() {
core.RegisterPlatform("weixin", New)
}
const (
sessionKeyPrefix = "weixin:dm:"
maxWeixinChunk = 3800 // stay under typical IM limits
// weixinSendMaxRetries is the maximum number of retries for sendMessage when API returns ret=-2.
weixinSendMaxRetries = 3
// weixinSendRetryDelay is the delay between retries when sendMessage fails.
weixinSendRetryDelay = 500 * time.Millisecond
// weixinChunkSendDelay is the delay between sending message chunks to avoid rate limiting.
weixinChunkSendDelay = 100 * time.Millisecond
// typingTicketTTL is how long a cached typing ticket remains valid.
typingTicketTTL = 10 * time.Minute
// typingRepeatInterval is how often to resend the typing status to keep it alive.
typingRepeatInterval = 5 * time.Second
)
type replyContext struct {
peerUserID string
contextToken string
}
// Platform implements core.Platform for Weixin personal chat via the ilink bot HTTP API
// (same backend as the OpenClaw openclaw-weixin plugin: long-poll getUpdates + sendMessage).
type Platform struct {
token string
baseURL string
cdnBaseURL string
allowFrom string
routeTag string
stateDir string
longPollMS int
accountLabel string
httpClient *http.Client
cdnHttpClient *http.Client // 专用于 CDN 上传/下载,不走代理
api *apiClient
mu sync.RWMutex
handler core.MessageHandler
cancel context.CancelFunc
stopping bool
syncBufMu sync.Mutex
syncBuf string
syncBufPath string
dedupMu sync.Mutex
dedup map[string]time.Time
pauseMu sync.Mutex
pauseUntil time.Time
tokensMu sync.RWMutex
tokens map[string]string
tokensPath string
typingMu sync.RWMutex
typingTickets map[string]typingTicketEntry // peerUserID → cached ticket
}
type typingTicketEntry struct {
ticket string
fetchedAt time.Time
}
func sanitizePathSegment(s string) string {
s = strings.TrimSpace(s)
if s == "" {
return "default"
}
var b strings.Builder
for _, r := range s {
switch r {
case '/', '\\', ':', '\x00':
b.WriteByte('_')
default:
b.WriteRune(r)
}
}
return b.String()
}
// New constructs a Weixin platform. Required options: token.
// Optional: base_url, cdn_base_url (default https://novac2c.cdn.weixin.qq.com/c2c), allow_from, route_tag, account_id, long_poll_timeout_ms,
// state_dir (override persistence dir), proxy, cc_data_dir + cc_project (injected by main).
func New(opts map[string]any) (core.Platform, error) {
token, _ := opts["token"].(string)
if strings.TrimSpace(token) == "" {
return nil, fmt.Errorf("weixin: token is required (ilink bot Bearer token)")
}
allowFrom, _ := opts["allow_from"].(string)
core.CheckAllowFrom("weixin", allowFrom)
baseURL, _ := opts["base_url"].(string)
cdnBaseURL, _ := opts["cdn_base_url"].(string)
if strings.TrimSpace(cdnBaseURL) == "" {
cdnBaseURL = defaultCDNBaseURL
}
cdnBaseURL = strings.TrimRight(strings.TrimSpace(cdnBaseURL), "/")
routeTag, _ := opts["route_tag"].(string)
accountLabel, _ := opts["account_id"].(string)
if accountLabel == "" {
accountLabel = "default"
}
lp := pickInt(opts["long_poll_timeout_ms"])
dataDir, _ := opts["cc_data_dir"].(string)
project, _ := opts["cc_project"].(string)
stateDir := ""
if dataDir != "" && project != "" {
safeProj := sanitizePathSegment(project)
stateDir = filepath.Join(dataDir, "weixin", safeProj, sanitizePathSegment(accountLabel))
}
if override, _ := opts["state_dir"].(string); strings.TrimSpace(override) != "" {
stateDir = strings.TrimSpace(override)
}
httpClient := &http.Client{Timeout: defaultAPITimeout}
if proxyURL, _ := opts["proxy"].(string); proxyURL != "" {
u, err := url.Parse(proxyURL)
if err != nil {
return nil, fmt.Errorf("weixin: invalid proxy URL %q: %w", proxyURL, err)
}
proxyUser, _ := opts["proxy_username"].(string)
proxyPass, _ := opts["proxy_password"].(string)
if proxyUser != "" {
u.User = url.UserPassword(proxyUser, proxyPass)
}
httpClient.Transport = &http.Transport{Proxy: http.ProxyURL(u)}
slog.Info("weixin: using proxy", "proxy", u.Redacted())
}
// CDN 客户端:微信国内 CDN 必须直连,绕过环境变量中的代理(如 HTTPS_PROXY)
cdnHttpClient := &http.Client{
Timeout: 60 * time.Second,
Transport: &http.Transport{Proxy: nil},
}
p := &Platform{
token: token,
baseURL: baseURL,
cdnBaseURL: cdnBaseURL,
allowFrom: allowFrom,
routeTag: routeTag,
stateDir: stateDir,
longPollMS: lp,
accountLabel: accountLabel,
httpClient: httpClient,
cdnHttpClient: cdnHttpClient,
tokens: make(map[string]string),
dedup: make(map[string]time.Time),
typingTickets: make(map[string]typingTicketEntry),
}
p.api = newAPIClient(baseURL, token, routeTag, httpClient)
if stateDir != "" {
if err := os.MkdirAll(stateDir, 0o755); err != nil {
return nil, fmt.Errorf("weixin: create state dir: %w", err)
}
p.syncBufPath = filepath.Join(stateDir, "get_updates.buf")
p.tokensPath = filepath.Join(stateDir, "context_tokens.json")
p.loadSyncBuf()
p.loadTokens()
}
return p, nil
}
func pickInt(v any) int {
switch x := v.(type) {
case int:
return x
case int64:
return int(x)
case float64:
return int(x)
default:
return 0
}
}
func (p *Platform) Name() string { return "weixin" }
func (p *Platform) loadSyncBuf() {
if p.syncBufPath == "" {
return
}
b, err := os.ReadFile(p.syncBufPath)
if err != nil {
return
}
p.syncBuf = string(b)
}
// persistSyncBuf writes buf as the next get_updates cursor (caller must hold syncBufMu).
func (p *Platform) persistSyncBuf(buf string) {
p.syncBuf = buf
if p.syncBufPath == "" {
return
}
if err := os.WriteFile(p.syncBufPath, []byte(buf), 0o600); err != nil {
slog.Warn("weixin: save sync buf failed", "path", p.syncBufPath, "error", err)
}
}
func (p *Platform) loadTokens() {
if p.tokensPath == "" {
return
}
b, err := os.ReadFile(p.tokensPath)
if err != nil {
return
}
var m map[string]string
if json.Unmarshal(b, &m) != nil {
return
}
p.tokensMu.Lock()
p.tokens = m
p.tokensMu.Unlock()
}
func (p *Platform) persistTokens() {
if p.tokensPath == "" {
return
}
p.tokensMu.RLock()
out, err := json.MarshalIndent(p.tokens, "", " ")
p.tokensMu.RUnlock()
if err != nil {
return
}
if err := os.WriteFile(p.tokensPath, out, 0o600); err != nil {
slog.Warn("weixin: save context tokens failed", "path", p.tokensPath, "error", err)
}
}
func (p *Platform) setContextToken(peer, tok string) {
if peer == "" || tok == "" {
return
}
p.tokensMu.Lock()
if p.tokens == nil {
p.tokens = make(map[string]string)
}
p.tokens[peer] = tok
p.tokensMu.Unlock()
p.persistTokens()
}
func (p *Platform) getContextToken(peer string) string {
p.tokensMu.RLock()
defer p.tokensMu.RUnlock()
return p.tokens[peer]
}
func (p *Platform) isPaused() bool {
p.pauseMu.Lock()
defer p.pauseMu.Unlock()
if p.pauseUntil.IsZero() || time.Now().After(p.pauseUntil) {
p.pauseUntil = time.Time{}
return false
}
return true
}
func (p *Platform) pauseSession(d time.Duration) {
if d <= 0 {
d = time.Hour
}
p.pauseMu.Lock()
p.pauseUntil = time.Now().Add(d)
p.pauseMu.Unlock()
slog.Warn("weixin: session paused after gateway error", "duration", d, "account", p.accountLabel)
}
func (p *Platform) Start(handler core.MessageHandler) error {
p.mu.Lock()
defer p.mu.Unlock()
if p.stopping {
return fmt.Errorf("weixin: platform stopped")
}
p.handler = handler
ctx, cancel := context.WithCancel(context.Background())
p.cancel = cancel
go p.pollLoop(ctx)
return nil
}
func (p *Platform) Stop() error {
p.mu.Lock()
if p.cancel != nil {
p.cancel()
p.cancel = nil
}
p.stopping = true
p.mu.Unlock()
return nil
}
func (p *Platform) pollLoop(ctx context.Context) {
backoff := time.Second
const maxBackoff = 30 * time.Second
for {
if ctx.Err() != nil {
return
}
if p.isPaused() {
select {
case <-ctx.Done():
return
case <-time.After(5 * time.Second):
continue
}
}
p.syncBufMu.Lock()
buf := p.syncBuf
p.syncBufMu.Unlock()
timeoutMs := p.longPollMS
resp, err := p.api.getUpdates(ctx, buf, timeoutMs)
if err != nil {
if ctx.Err() != nil {
return
}
slog.Warn("weixin: getUpdates failed", "error", err, "backoff", backoff)
time.Sleep(backoff)
if backoff < maxBackoff {
backoff *= 2
if backoff > maxBackoff {
backoff = maxBackoff
}
}
continue
}
backoff = time.Second
if resp.Errcode == sessionExpiredErrcode {
p.pauseSession(time.Hour)
continue
}
if resp.Ret != 0 && resp.Errmsg != "" {
slog.Warn("weixin: getUpdates ret", "ret", resp.Ret, "errcode", resp.Errcode, "errmsg", resp.Errmsg)
}
p.mu.RLock()
h := p.handler
p.mu.RUnlock()
if h == nil {
continue
}
var wg sync.WaitGroup
for i := range resp.Msgs {
i := i
wg.Add(1)
go func() {
defer wg.Done()
p.dispatchInbound(ctx, &resp.Msgs[i], h)
}()
}
wg.Wait()
if ctx.Err() == nil && resp.GetUpdatesBuf != "" {
p.syncBufMu.Lock()
p.persistSyncBuf(resp.GetUpdatesBuf)
p.syncBufMu.Unlock()
}
}
}
func (p *Platform) dispatchInbound(ctx context.Context, m *weixinMessage, h core.MessageHandler) {
if m == nil {
return
}
if m.MessageType == messageTypeBot {
return
}
if m.MessageType != 0 && m.MessageType != messageTypeUser {
return
}
from := strings.TrimSpace(m.FromUserID)
if from == "" {
return
}
if !core.AllowList(p.allowFrom, from) {
slog.Debug("weixin: sender not in allow_from", "from", from)
return
}
if m.CreateTimeMs > 0 {
t := time.UnixMilli(m.CreateTimeMs)
if core.IsOldMessage(t) {
slog.Debug("weixin: skip old message", "time", t)
return
}
}
// Include create_time_ms and client_id so (seq,message_id)=(0,0) or duplicates are less likely to collide.
dedupKey := fmt.Sprintf("%s|%d|%d|%d|%s", from, m.MessageID, m.Seq, m.CreateTimeMs, strings.TrimSpace(m.ClientID))
p.dedupMu.Lock()
if p.dedup == nil {
p.dedup = make(map[string]time.Time)
}
now := time.Now()
for k, ts := range p.dedup {
if now.Sub(ts) > 5*time.Minute {
delete(p.dedup, k)
}
}
if _, ok := p.dedup[dedupKey]; ok {
p.dedupMu.Unlock()
return
}
p.dedup[dedupKey] = now
p.dedupMu.Unlock()
if tok := strings.TrimSpace(m.ContextToken); tok != "" {
p.setContextToken(from, tok)
p.refreshTypingTicket(ctx, from, tok)
}
body := bodyFromItemList(m.ItemList)
images, files, audio := p.collectInboundMedia(ctx, m.ItemList)
if strings.TrimSpace(body) == "" && len(images) == 0 && len(files) == 0 && audio == nil && mediaOnlyItems(m.ItemList) {
body = "[收到媒体消息:CDN 下载或解密失败,或未配置 cdn_base_url;请改用文字说明。]"
}
if strings.TrimSpace(body) == "" && len(images) == 0 && len(files) == 0 && audio == nil {
return
}
rc := &replyContext{peerUserID: from, contextToken: strings.TrimSpace(m.ContextToken)}
msgID := fmt.Sprintf("%d", m.MessageID)
if m.MessageID == 0 {
msgID = randomHex(8)
}
h(p, &core.Message{
SessionKey: sessionKeyPrefix + from,
Platform: p.Name(),
MessageID: msgID,
UserID: from,
UserName: shortWeixinUser(from),
Content: body,
Images: images,
Files: files,
Audio: audio,
ReplyCtx: rc,
})
}
func mediaOnlyItems(items []messageItem) bool {
for _, it := range items {
switch it.Type {
case messageItemImage, messageItemVideo, messageItemFile:
return true
case messageItemVoice:
if it.VoiceItem == nil || strings.TrimSpace(it.VoiceItem.Text) == "" {
return true
}
}
}
return false
}
func shortWeixinUser(id string) string {
if len(id) > 32 {
return id[:32] + "…"
}
return id
}
func randomHex(n int) string {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
return fmt.Sprintf("%d", time.Now().UnixNano())
}
return hex.EncodeToString(b)
}
func (p *Platform) Reply(ctx context.Context, replyCtx any, content string) error {
return p.sendChunks(ctx, replyCtx, content)
}
func (p *Platform) Send(ctx context.Context, replyCtx any, content string) error {
return p.sendChunks(ctx, replyCtx, content)
}
// StartTyping sends a typing indicator to the peer and repeats every few seconds
// until the returned stop function is called. Implements core.TypingIndicator.
func (p *Platform) StartTyping(ctx context.Context, rctx any) (stop func()) {
rc, ok := rctx.(*replyContext)
if !ok || rc == nil {
return func() {}
}
peerID := rc.peerUserID
contextToken := rc.contextToken
if strings.TrimSpace(contextToken) == "" {
contextToken = p.getContextToken(peerID)
}
ticket := p.getTypingTicket(ctx, peerID, contextToken)
if ticket == "" {
return func() {}
}
if err := p.api.sendTyping(ctx, peerID, ticket, typingStatusStart); err != nil {
slog.Debug("weixin: initial typing start failed", "peer", peerID, "error", err)
return func() {}
}
done := make(chan struct{})
go func() {
ticker := time.NewTicker(typingRepeatInterval)
defer ticker.Stop()
for {
select {
case <-done:
// Best-effort stop; use background context since ctx may already be cancelled.
stopCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
if err := p.api.sendTyping(stopCtx, peerID, ticket, typingStatusStop); err != nil {
slog.Debug("weixin: typing stop failed", "peer", peerID, "error", err)
}
cancel()
return
case <-ctx.Done():
stopCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
if err := p.api.sendTyping(stopCtx, peerID, ticket, typingStatusStop); err != nil {
slog.Debug("weixin: typing stop failed (ctx cancelled)", "peer", peerID, "error", err)
}
cancel()
return
case <-ticker.C:
if err := p.api.sendTyping(ctx, peerID, ticket, typingStatusStart); err != nil {
slog.Debug("weixin: typing repeat failed", "peer", peerID, "error", err)
bestEffortCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
_ = p.api.sendTyping(bestEffortCtx, peerID, ticket, typingStatusStop)
cancel()
return
}
}
}
}()
return func() { close(done) }
}
// getTypingTicket returns a cached typing ticket for the peer, fetching one
// from the getconfig API if the cache is empty or expired.
func (p *Platform) getTypingTicket(ctx context.Context, peerID, contextToken string) string {
p.typingMu.RLock()
entry, ok := p.typingTickets[peerID]
p.typingMu.RUnlock()
if ok && time.Since(entry.fetchedAt) < typingTicketTTL {
return entry.ticket
}
resp, err := p.api.getConfig(ctx, peerID, contextToken)
if err != nil {
slog.Debug("weixin: getConfig for typing ticket failed", "peer", peerID, "error", err)
return ""
}
ticket := strings.TrimSpace(resp.TypingTicket)
if ticket == "" {
return ""
}
p.typingMu.Lock()
p.typingTickets[peerID] = typingTicketEntry{ticket: ticket, fetchedAt: time.Now()}
p.typingMu.Unlock()
return ticket
}
// refreshTypingTicket proactively fetches and caches a typing ticket when a
// message is received, so that StartTyping can use it without an extra round-trip.
func (p *Platform) refreshTypingTicket(ctx context.Context, peerID, contextToken string) {
go func() {
fetchCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
p.getTypingTicket(fetchCtx, peerID, contextToken)
}()
}
func (p *Platform) sendChunks(ctx context.Context, replyCtx any, content string) error {
rc, ok := replyCtx.(*replyContext)
if !ok || rc == nil {
return fmt.Errorf("weixin: invalid reply context")
}
if strings.TrimSpace(rc.contextToken) == "" {
rc.contextToken = p.getContextToken(rc.peerUserID)
}
if strings.TrimSpace(rc.contextToken) == "" {
slog.Error("weixin: cannot send message - missing context_token",
"peer", rc.peerUserID,
"content_preview", truncatePreview(content, 100),
"hint", "user needs to send a new message to refresh context_token")
return fmt.Errorf("weixin: missing context_token for peer %q - user must send a new message first", rc.peerUserID)
}
if strings.TrimSpace(content) == "" {
return nil
}
chunks := splitUTF8(content, maxWeixinChunk)
total := len(chunks)
for i, chunk := range chunks {
// Add delay between chunks to avoid rate limiting (except for first chunk)
if i > 0 {
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(weixinChunkSendDelay):
}
}
// Retry sendText with context_token refresh on failure
err := p.sendChunkWithRetry(ctx, rc, chunk, i+1, total)
if err != nil {
slog.Error("weixin: chunk send failed, message incomplete",
"peer", rc.peerUserID,
"failed_chunk", fmt.Sprintf("%d/%d", i+1, total),
"error", err)
// Notify user that message delivery was incomplete.
// Use a short message that is unlikely to fail itself.
notice := "⚠️ 消息发送不完整,请在终端查看完整结果。"
noticeID := "cc-" + randomHex(6)
if nerr := p.api.sendText(ctx, rc.peerUserID, notice, rc.contextToken, noticeID); nerr != nil {
slog.Warn("weixin: failed to send incomplete-delivery notice", "peer", rc.peerUserID, "error", nerr)
}
return fmt.Errorf("weixin: send chunk %d/%d: %w", i+1, total, err)
}
}
return nil
}
// sendChunkWithRetry sends a single chunk with retry mechanism.
// When sendMessage returns ret=-2, it tries to refresh the context_token from
// storage (which is updated by every inbound message) before retrying.
// If the stored token is the same as the current one (no refresh possible),
// it fails fast rather than burning retries on a stale token.
// chunkIdx and totalChunks are 1-based indices used for logging context.
func (p *Platform) sendChunkWithRetry(ctx context.Context, rc *replyContext, chunk string, chunkIdx, totalChunks int) error {
var lastErr error
for attempt := 0; attempt < weixinSendMaxRetries; attempt++ {
clientID := "cc-" + randomHex(6)
err := p.api.sendText(ctx, rc.peerUserID, chunk, rc.contextToken, clientID)
if err == nil {
return nil
}
lastErr = err
// Check if error is ret=-2 (API declined) - attempt token refresh
if strings.Contains(err.Error(), "ret=-2") {
preview := []rune(chunk)
if len(preview) > 50 {
preview = preview[:50]
}
// Refresh context_token from stored tokens (may have been updated by a
// concurrent inbound message while we were waiting).
freshToken := p.getContextToken(rc.peerUserID)
if freshToken == "" || freshToken == rc.contextToken {
// No fresh token available — further retries would use the same stale
// token and all fail. Fail fast with an actionable error.
slog.Warn("weixin: sendMessage ret=-2, no fresh context_token available — "+
"user must send a new message to refresh the session token",
"attempt", attempt+1, "peer", rc.peerUserID,
"chunk", fmt.Sprintf("%d/%d", chunkIdx, totalChunks),
"chunk_runes", utf8.RuneCountInString(chunk),
"preview", string(preview))
return fmt.Errorf("weixin: sendMessage ret=-2 (expired context_token); "+
"user must send a new message to peer %q to refresh the session token: %w",
rc.peerUserID, lastErr)
}
slog.Warn("weixin: sendMessage ret=-2, retrying with fresh context_token",
"attempt", attempt+1, "peer", rc.peerUserID,
"chunk", fmt.Sprintf("%d/%d", chunkIdx, totalChunks),
"chunk_runes", utf8.RuneCountInString(chunk),
"preview", string(preview))
rc.contextToken = freshToken
slog.Debug("weixin: using refreshed context_token for retry", "peer", rc.peerUserID)
// Brief delay before retry
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(weixinSendRetryDelay):
}
continue
}
// For other errors, don't retry
return err
}
return lastErr
}
func truncatePreview(s string, max int) string {
if len(s) <= max {
return s
}
return s[:max] + "..."
}
func splitUTF8(s string, maxRunes int) []string {
if maxRunes <= 0 || utf8.RuneCountInString(s) <= maxRunes {
return []string{s}
}
var out []string
runes := []rune(s)
for len(runes) > 0 {
n := maxRunes
if len(runes) < n {
n = len(runes)
}
out = append(out, string(runes[:n]))
runes = runes[n:]
}
return out
}
// ReconstructReplyCtx implements core.ReplyContextReconstructor for cron / proactive sends.
func (p *Platform) ReconstructReplyCtx(sessionKey string) (any, error) {
if !strings.HasPrefix(sessionKey, sessionKeyPrefix) {
return nil, fmt.Errorf("weixin: not a weixin session key")
}
peer := strings.TrimPrefix(sessionKey, sessionKeyPrefix)
tok := p.getContextToken(peer)
if tok == "" {
return nil, fmt.Errorf("weixin: no stored context_token for %q (user must message the bot first)", peer)
}
return &replyContext{peerUserID: peer, contextToken: tok}, nil
}
// FormattingInstructions implements core.FormattingInstructionProvider.
func (p *Platform) FormattingInstructions() string {
return "Replies are delivered as plain text to Weixin. Avoid markdown tables; use short paragraphs."
}
var (
_ core.Platform = (*Platform)(nil)
_ core.ReplyContextReconstructor = (*Platform)(nil)
_ core.FormattingInstructionProvider = (*Platform)(nil)
_ core.ImageSender = (*Platform)(nil)
_ core.FileSender = (*Platform)(nil)
_ core.TypingIndicator = (*Platform)(nil)
)