weixin_clawbot 0.3.0 copy "weixin_clawbot: ^0.3.0" to clipboard
weixin_clawbot: ^0.3.0 copied to clipboard

A pure Dart package for binding WeChat via QR code and receiving/sending messages through the WeChat iLink Bot API (OpenClaw ClawBot). Works with both SDKs: Dart servers/CLIs and Flutter mobile/deskto [...]

example/lib/main.dart

import 'dart:typed_data';

import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import 'package:weixin_clawbot/weixin_clawbot.dart';
import 'qr_login_widget.dart';

/// Flutter Web 需要通过本地 CORS 代理访问 iLink API。
/// 运行代理服务器:dart run bin/proxy_server.dart
/// 然后用 --dart-define=PROXY_URL=http://localhost:3001 启动 Flutter Web。
const _webProxyUrl = String.fromEnvironment(
  'PROXY_URL',
  defaultValue: 'http://localhost:3001',
);

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'WeChat ClawBot 示例',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.green),
        useMaterial3: true,
      ),
      home: const HomePage(),
    );
  }
}

/// 应用全局唯一的 ClawBot 实例。
/// 在 Flutter Web 上通过本地 CORS 代理转发请求;原生平台直连 iLink API。
final _clawbot = WeixinClawbot(
  proxyBaseUrl: kIsWeb ? _webProxyUrl : null,
);

// ─────────────────────────────────────────────────────────────────────────────

/// 示例应用的首页,整合了扫码绑定、消息接收与发送三个核心流程。
///
/// 页面状态由 [_account] 决定:
/// - `null`  → 显示扫码绑定页([_buildLogin])
/// - 非 null → 显示聊天页([_buildChat])
class HomePage extends StatefulWidget {
  const HomePage({super.key});

  @override
  State<HomePage> createState() => _HomePageState();
}

class _HomePageState extends State<HomePage> {
  ClawBotAccount? _account;
  final List<WeixinMessage> _messages = [];
  final _controller = TextEditingController();
  final _toController = TextEditingController();
  bool _sending = false;

  /// Flutter Web:本地 CORS 代理可达性(null=检测中)。
  bool? _proxyReachable;

  @override
  void initState() {
    super.initState();
    // 应用启动时尝试从本地存储中恢复已绑定账号,若存在则直接重连。
    _init();
    _checkProxy();
  }

  /// 探测本地 CORS 代理是否在线。任何 HTTP 响应(包括上游的 404)都算
  /// 可达;只有网络层失败(连接拒绝等)才算离线。
  Future<void> _checkProxy() async {
    if (!kIsWeb) return;
    try {
      await http
          .get(Uri.parse('$_webProxyUrl/'))
          .timeout(const Duration(seconds: 4));
      if (mounted) setState(() => _proxyReachable = true);
    } catch (_) {
      if (mounted) setState(() => _proxyReachable = false);
    }
  }

  /// 从持久化存储中加载已绑定账号并自动重连。
  /// 如果尚未绑定,[_account] 保持为 null,页面展示扫码入口。
  Future<void> _init() async {
    final account = await _clawbot.loadAccount();
    if (account != null && mounted) {
      _connectAccount(account);
    }
  }

  /// 将 [account] 设为当前账号并启动长轮询消息监听。
  ///
  /// - 更新 UI 状态(切换到聊天页)。
  /// - 若收件人框为空,用 [ClawBotAccount.defaultTo] 预填(绑定时自动写入)。
  /// - 监听 [WeixinClawbot.connect] 返回的消息流,收到消息后追加到列表。
  void _connectAccount(ClawBotAccount account) {
    setState(() => _account = account);
    if (_toController.text.isEmpty && account.defaultTo != null) {
      _toController.text = account.defaultTo!;
    }
    _clawbot.connect(account).listen((msg) {
      if (mounted) {
        setState(() => _messages.add(msg));
      }
    });
  }

  /// 弹出 QR 扫码绑定对话框。
  ///
  /// 用户在微信扫码并确认后,[showQrLoginDialog] 返回新账号;
  /// 此时调用 [_connectAccount] 切换页面并开始接收消息。
  Future<void> _login() async {
    final account = await showQrLoginDialog(
      context: context,
      clawbot: _clawbot,
    );
    if (account != null && mounted) {
      _connectAccount(account);
    }
  }

  /// 发送输入框中的文本消息。
  ///
  /// 校验逻辑:
  /// 1. 消息内容不能为空。
  /// 2. 收件人 ID 不能为空(通常由 [ClawBotAccount.defaultTo] 自动填充)。
  ///
  /// 发送成功后清空消息输入框;失败时通过 [SnackBar] 显示错误原因。
  Future<void> _send() async {
    final text = _controller.text.trim();
    final to = _toController.text.trim();
    if (text.isEmpty) return;
    if (to.isEmpty) {
      ScaffoldMessenger.of(context).showSnackBar(
        const SnackBar(content: Text('请先填写收件人 ID')),
      );
      return;
    }

    setState(() => _sending = true);
    final result = await _clawbot.sendText(text: text, toUserId: to);
    setState(() => _sending = false);

    if (!mounted) return;
    if (!result.ok) {
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(content: SelectableText('发送失败:${result.error}')),
      );
    } else {
      _controller.clear();
      // 服务器不会在 getupdates 长轮询里回显 bot 自己发出的消息(与官方
      // openclaw-weixin 行为一致),这里本地乐观插入,让聊天界面立即显示
      // 刚刚发送的内容。messageType=2 → 绿色气泡、居右。
      setState(() {
        _messages.add(WeixinMessage(
          seq: 0,
          messageId: 0,
          fromUserId: _account!.botId,
          toUserId: to,
          clientId: result.clientId,
          createTimeMs: DateTime.now().millisecondsSinceEpoch,
          messageType: 2,
          messageState: 2,
          items: [
            MessageItem(type: MessageItemType.text, text: text),
          ],
        ));
      });
    }
  }

  /// 解绑当前账号:清除持久化凭证、停止消息轮询、重置页面状态。
  Future<void> _logout() async {
    await _clawbot.logout();
    if (mounted) {
      setState(() {
        _account = null;
        _messages.clear();
      });
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('WeChat ClawBot'),
        actions: [
          if (_account != null)
            IconButton(
              icon: const Icon(Icons.logout),
              tooltip: '解绑',
              onPressed: _logout,
            ),
        ],
      ),
      body: _account == null ? _buildLogin() : _buildChat(),
    );
  }

  Widget _buildLogin() {
    return Center(
      child: Column(
        mainAxisSize: MainAxisSize.min,
        children: [
          if (kIsWeb)
            _proxyReachable == true
                ? const _ProxyStatusChip(proxyUrl: _webProxyUrl)
                : _WebProxyBanner(
                    proxyUrl: _webProxyUrl,
                    checking: _proxyReachable == null,
                    onRetry: _checkProxy,
                  ),
          const Icon(Icons.qr_code, size: 64, color: Colors.grey),
          const SizedBox(height: 16),
          const Text('尚未绑定微信账号'),
          const SizedBox(height: 24),
          FilledButton.icon(
            onPressed: _login,
            icon: const Icon(Icons.link),
            label: const Text('扫码绑定微信'),
          ),
        ],
      ),
    );
  }

  Widget _buildChat() {
    return Column(
      children: [
        // Account info banner
        Container(
          color: Colors.green.shade50,
          padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
          child: Row(
            children: [
              const Icon(Icons.check_circle, color: Colors.green, size: 16),
              const SizedBox(width: 8),
              Expanded(
                child: Text(
                  '已绑定 · Bot: ${_account!.botId}',
                  style: const TextStyle(fontSize: 12),
                  overflow: TextOverflow.ellipsis,
                ),
              ),
            ],
          ),
        ),

        // Message list
        Expanded(
          child: _messages.isEmpty
              ? const Center(
                  child: Text(
                    '暂无消息\n等待微信消息中…',
                    textAlign: TextAlign.center,
                    style: TextStyle(color: Colors.grey),
                  ),
                )
              : ListView.builder(
                  padding: const EdgeInsets.all(8),
                  itemCount: _messages.length,
                  itemBuilder: (_, i) => _MessageBubble(
                    msg: _messages[i],
                    downloadImage: _clawbot.downloadImage,
                  ),
                ),
        ),

        // Input bar
        Padding(
          padding: EdgeInsets.fromLTRB(
            8,
            4,
            8,
            MediaQuery.of(context).viewInsets.bottom + 8,
          ),
          child: Column(
            mainAxisSize: MainAxisSize.min,
            children: [
              TextField(
                controller: _toController,
                decoration: const InputDecoration(
                  hintText: '收件人 ID(to_user_id)',
                  labelText: '收件人',
                  border: OutlineInputBorder(),
                  contentPadding:
                      EdgeInsets.symmetric(horizontal: 12, vertical: 8),
                  isDense: true,
                ),
                style: const TextStyle(fontSize: 12),
              ),
              const SizedBox(height: 6),
              Row(
                children: [
                  Expanded(
                    child: TextField(
                      controller: _controller,
                      decoration: const InputDecoration(
                        hintText: '发送消息…',
                        border: OutlineInputBorder(),
                        contentPadding:
                            EdgeInsets.symmetric(horizontal: 12, vertical: 8),
                      ),
                      onSubmitted: (_) => _send(),
                    ),
                  ),
                  const SizedBox(width: 8),
                  _sending
                      ? const SizedBox(
                          width: 44,
                          height: 44,
                          child: CircularProgressIndicator(strokeWidth: 2),
                        )
                      : IconButton.filled(
                          onPressed: _send,
                          icon: const Icon(Icons.send),
                        ),
                ],
              ),
            ],
          ),
        ),
      ],
    );
  }

  @override
  void dispose() {
    _controller.dispose();
    _toController.dispose();
    _clawbot.dispose();
    super.dispose();
  }
}

// ─────────────────────────────────────────────────────────────────────────────

class _MessageBubble extends StatelessWidget {
  final WeixinMessage msg;

  /// Downloads & decrypts an inbound image item (provided by the app's
  /// `WeixinClawbot` instance).
  final Future<Uint8List> Function(ImageItemData) downloadImage;

  const _MessageBubble({required this.msg, required this.downloadImage});

  @override
  Widget build(BuildContext context) {
    final isUser = msg.isFromUser;
    final time = DateTime.fromMillisecondsSinceEpoch(msg.createTimeMs);
    final timeStr =
        '${time.hour.toString().padLeft(2, '0')}:${time.minute.toString().padLeft(2, '0')}';

    // Prefer the first image item; fall back to text (or a placeholder for
    // other non-text types such as files/videos).
    final image = msg.imageItem;
    final text = msg.textContent ??
        (image == null && msg.items.isNotEmpty ? '[非文本消息]' : '');

    return Align(
      alignment: isUser ? Alignment.centerLeft : Alignment.centerRight,
      child: Container(
        margin: const EdgeInsets.symmetric(vertical: 4, horizontal: 8),
        padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
        constraints: BoxConstraints(
          maxWidth: MediaQuery.of(context).size.width * 0.72,
        ),
        decoration: BoxDecoration(
          color: isUser ? Colors.grey.shade200 : Colors.green.shade100,
          borderRadius: BorderRadius.circular(12),
        ),
        child: Column(
          crossAxisAlignment:
              isUser ? CrossAxisAlignment.start : CrossAxisAlignment.end,
          children: [
            if (image != null)
              _ImageMessageView(image: image, downloadImage: downloadImage),
            if (text.isNotEmpty) ...[
              if (image != null) const SizedBox(height: 4),
              Text(text),
            ],
            const SizedBox(height: 2),
            Text(
              '$timeStr · ${msg.fromUserId.split('@').first}',
              style: const TextStyle(fontSize: 10, color: Colors.grey),
            ),
          ],
        ),
      ),
    );
  }
}

/// Renders an inbound picture: downloads + decrypts it once and keeps the
/// result cached for the lifetime of this widget.
class _ImageMessageView extends StatefulWidget {
  final ImageItemData image;
  final Future<Uint8List> Function(ImageItemData) downloadImage;

  const _ImageMessageView({required this.image, required this.downloadImage});

  @override
  State<_ImageMessageView> createState() => _ImageMessageViewState();
}

class _ImageMessageViewState extends State<_ImageMessageView> {
  late Future<Uint8List> _future;

  @override
  void initState() {
    super.initState();
    _future = _download();
  }

  Future<Uint8List> _download() async {
    try {
      return await widget.downloadImage(widget.image);
    } catch (e) {
      // Surface the real cause (CORS/proxy、HTTP 状态码、解密失败等) in the
      // console — the bubble itself only shows a short headline.
      debugPrint('图片下载失败: $e');
      rethrow;
    }
  }

  void _retry() {
    setState(() => _future = _download());
  }

  @override
  Widget build(BuildContext context) {
    // 没有可用的下载参数(无 encrypt_query_param / full_url)时不要发起
    // 注定失败的请求,直接显示占位文本。
    if (!widget.image.isDownloadable) {
      return const Text('[图片]',
          style: TextStyle(fontSize: 12, color: Colors.grey));
    }
    return FutureBuilder<Uint8List>(
      future: _future,
      builder: (context, snapshot) {
        if (snapshot.connectionState != ConnectionState.done) {
          return const SizedBox(
            width: 160,
            height: 120,
            child: Center(child: CircularProgressIndicator(strokeWidth: 2)),
          );
        }
        if (snapshot.hasError) {
          final detail = '${snapshot.error}';
          return InkWell(
            onTap: _retry,
            child: Column(
              mainAxisSize: MainAxisSize.min,
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                const Row(
                  mainAxisSize: MainAxisSize.min,
                  children: [
                    Icon(Icons.broken_image_outlined,
                        size: 18, color: Colors.grey),
                    SizedBox(width: 6),
                    Flexible(
                      child: Text(
                        '图片下载失败(点击重试)',
                        style: TextStyle(fontSize: 12, color: Colors.grey),
                      ),
                    ),
                  ],
                ),
                Flexible(
                  child: SelectableText(
                    detail,
                    maxLines: 3,
                    style: const TextStyle(fontSize: 10, color: Colors.grey),
                  ),
                ),
              ],
            ),
          );
        }
        return ClipRRect(
          borderRadius: BorderRadius.circular(8),
          child: Image.memory(
            snapshot.data!,
            fit: BoxFit.contain,
            width: 240,
            errorBuilder: (_, __, ___) =>
                const Text('[图片](无法解码)', style: TextStyle(fontSize: 12)),
          ),
        );
      },
    );
  }
}

// ─────────────────────────────────────────────────────────────────────────────

/// Shown on Flutter Web to guide the user to start the CORS proxy server.
class _WebProxyBanner extends StatelessWidget {
  final String proxyUrl;

  /// null = 正在检测代理,非 null = 上次检测离线后用户可重试。
  final bool checking;
  final Future<void> Function()? onRetry;

  const _WebProxyBanner({
    required this.proxyUrl,
    this.checking = false,
    this.onRetry,
  });

  @override
  Widget build(BuildContext context) {
    return Container(
      margin: const EdgeInsets.only(bottom: 24),
      padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
      decoration: BoxDecoration(
        color: Colors.amber.shade50,
        border: Border.all(color: Colors.amber.shade300),
        borderRadius: BorderRadius.circular(8),
      ),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        mainAxisSize: MainAxisSize.min,
        children: [
          Row(
            children: [
              Icon(Icons.info_outline, size: 16, color: Colors.amber.shade800),
              const SizedBox(width: 6),
              Text(
                checking ? '正在检查 CORS 代理…' : '未连接到 CORS 代理',
                style: TextStyle(
                  fontWeight: FontWeight.bold,
                  color: Colors.amber.shade900,
                  fontSize: 13,
                ),
              ),
            ],
          ),
          const SizedBox(height: 6),
          Text(
            '在 example 目录运行:',
            style: TextStyle(fontSize: 12, color: Colors.amber.shade900),
          ),
          const SizedBox(height: 4),
          Container(
            padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
            decoration: BoxDecoration(
              color: Colors.grey.shade900,
              borderRadius: BorderRadius.circular(4),
            ),
            child: const Text(
              'dart run bin/proxy_server.dart',
              style: TextStyle(
                fontFamily: 'monospace',
                fontSize: 12,
                color: Colors.greenAccent,
              ),
            ),
          ),
          const SizedBox(height: 4),
          Text(
            '代理地址:$proxyUrl',
            style: TextStyle(fontSize: 11, color: Colors.amber.shade700),
          ),
          if (!checking && onRetry != null) ...[
            const SizedBox(height: 8),
            OutlinedButton.icon(
              onPressed: onRetry,
              icon: const Icon(Icons.refresh, size: 16),
              label: const Text('我已启动代理,重新检测'),
            ),
          ],
        ],
      ),
    );
  }
}

/// 代理在线时的绿色确认条。
class _ProxyStatusChip extends StatelessWidget {
  final String proxyUrl;

  const _ProxyStatusChip({required this.proxyUrl});
  @override
  Widget build(BuildContext context) {
    return Container(
      margin: const EdgeInsets.only(bottom: 24),
      padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
      decoration: BoxDecoration(
        color: Colors.green.shade50,
        border: Border.all(color: Colors.green.shade300),
        borderRadius: BorderRadius.circular(8),
      ),
      child: Row(
        mainAxisSize: MainAxisSize.min,
        children: [
          Icon(Icons.check_circle, size: 15, color: Colors.green.shade700),
          const SizedBox(width: 6),
          Text(
            '已连接 CORS 代理:$proxyUrl',
            style: TextStyle(fontSize: 12, color: Colors.green.shade800),
          ),
        ],
      ),
    );
  }
}
1
likes
130
points
21
downloads

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

A pure Dart package for binding WeChat via QR code and receiving/sending messages through the WeChat iLink Bot API (OpenClaw ClawBot). Works with both SDKs: Dart servers/CLIs and Flutter mobile/desktop/web apps. The core protocol layer has no Flutter dependency; a ready-made Flutter login UI is included in the package's example app (lib/qr_login_widget.dart).

Repository (GitHub)
View/report issues

Topics

#wechat #dart #http #api

License

MIT (license)

Dependencies

http, path, pointycastle, uuid, web

More

Packages that depend on weixin_clawbot