mirror of
https://github.com/memohai/Memoh.git
synced 2026-04-27 07:16:19 +09:00
feat(channel): add WeChat (weixin) adapter with QR code (#278)
* feat(channel): add WeChat (weixin) adapter with QR code * fix(channel): fix weixin block streaming * chore(channel): update weixin logo
This commit is contained in:
@@ -36,6 +36,7 @@
|
||||
"monaco-editor": "^0.52.2",
|
||||
"pinia": "^3.0.4",
|
||||
"pinia-plugin-persistedstate": "^4.7.1",
|
||||
"qrcode": "^1.5.4",
|
||||
"shiki": "^3.21.0",
|
||||
"stream-markdown": "^0.0.13",
|
||||
"stream-monaco": "^0.0.18",
|
||||
@@ -52,6 +53,7 @@
|
||||
"devDependencies": {
|
||||
"@memoh/config": "workspace:*",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@vitejs/plugin-vue": "^6.0.5",
|
||||
"@vue/tsconfig": "^0.8.1",
|
||||
"typescript": "~5.9.3",
|
||||
|
||||
@@ -27,6 +27,7 @@ const channelIcons: Record<string, Component> = {
|
||||
slack: Slack,
|
||||
feishu: Feishu,
|
||||
wechat: Wechat,
|
||||
weixin: Wechat,
|
||||
matrix: Matrix,
|
||||
}
|
||||
|
||||
|
||||
@@ -892,12 +892,24 @@
|
||||
"feishuWebhookSecurityHint": "For security, webhook mode requires either an Encrypt Key or a Verification Token; an unprotected public callback URL should not be exposed.",
|
||||
"feishuWebhookSecretRequired": "For security, configure at least one of Encrypt Key or Verification Token.",
|
||||
"noAvailableTypes": "All platform types have been configured",
|
||||
"weixinQr": {
|
||||
"title": "QR Code Login",
|
||||
"description": "Scan the QR code with WeChat to connect your account.",
|
||||
"startScan": "Get QR Code",
|
||||
"waitingScan": "Open WeChat and scan this QR code",
|
||||
"scanned": "Scanned — confirm on your phone",
|
||||
"expired": "QR code expired",
|
||||
"refresh": "Refresh QR Code",
|
||||
"success": "WeChat connected successfully!",
|
||||
"retry": "Try Again"
|
||||
},
|
||||
"types": {
|
||||
"feishu": "Feishu",
|
||||
"discord": "Discord",
|
||||
"qq": "QQ",
|
||||
"matrix": "Matrix",
|
||||
"telegram": "Telegram",
|
||||
"weixin": "WeChat",
|
||||
"web": "Web",
|
||||
"local": "Local"
|
||||
},
|
||||
@@ -907,6 +919,7 @@
|
||||
"qq": "QQ",
|
||||
"matrix": "MX",
|
||||
"telegram": "TG",
|
||||
"weixin": "WX",
|
||||
"web": "Web",
|
||||
"local": "CLI"
|
||||
}
|
||||
|
||||
@@ -888,12 +888,24 @@
|
||||
"feishuWebhookSecurityHint": "出于安全考虑,Webhook 模式必须配置 Encrypt Key 或 Verification Token 之一;未受保护的回调地址不应直接暴露在公网上。",
|
||||
"feishuWebhookSecretRequired": "出于安全考虑,请至少配置 Encrypt Key 或 Verification Token 之一。",
|
||||
"noAvailableTypes": "所有平台类型均已配置",
|
||||
"weixinQr": {
|
||||
"title": "扫码登录",
|
||||
"description": "使用微信扫描二维码以连接微信账号。",
|
||||
"startScan": "获取二维码",
|
||||
"waitingScan": "打开微信扫描此二维码",
|
||||
"scanned": "已扫码 — 请在手机上确认",
|
||||
"expired": "二维码已过期",
|
||||
"refresh": "刷新二维码",
|
||||
"success": "微信连接成功!",
|
||||
"retry": "重试"
|
||||
},
|
||||
"types": {
|
||||
"feishu": "飞书",
|
||||
"discord": "Discord",
|
||||
"qq": "QQ",
|
||||
"matrix": "Matrix",
|
||||
"telegram": "Telegram",
|
||||
"weixin": "微信",
|
||||
"web": "Web",
|
||||
"local": "本地"
|
||||
},
|
||||
@@ -903,6 +915,7 @@
|
||||
"qq": "QQ",
|
||||
"matrix": "MX",
|
||||
"telegram": "TG",
|
||||
"weixin": "WX",
|
||||
"web": "Web",
|
||||
"local": "CLI"
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
:aria-pressed="selectedType === item.meta.type"
|
||||
class="flex w-full items-center gap-3 rounded-md px-3 py-2.5 text-sm transition-colors hover:bg-accent"
|
||||
:class="{ 'bg-accent': selectedType === item.meta.type }"
|
||||
@click="selectedType = item.meta.type as string"
|
||||
@click="selectedType = item.meta.type ?? ''"
|
||||
>
|
||||
<span class="flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground">
|
||||
<ChannelIcon :channel="item.meta.type as string" size="1.25em" />
|
||||
@@ -98,7 +98,7 @@
|
||||
:key="item.meta.type"
|
||||
type="button"
|
||||
class="flex w-full items-center gap-3 rounded-md px-3 py-2 text-sm hover:bg-accent transition-colors"
|
||||
@click="addChannel(item.meta.type)"
|
||||
@click="addChannel(item.meta.type ?? '')"
|
||||
>
|
||||
<span class="flex size-7 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground">
|
||||
<ChannelIcon :channel="item.meta.type" size="1em" />
|
||||
@@ -168,7 +168,7 @@ const { data: channels, isLoading, refetch } = useQuery({
|
||||
configurableTypes.map(async (meta) => {
|
||||
try {
|
||||
const { data: config } = await getBotsByIdChannelByPlatform({
|
||||
path: { id: botIdRef.value, platform: meta.type },
|
||||
path: { id: botIdRef.value, platform: meta.type ?? '' },
|
||||
throwOnError: true,
|
||||
})
|
||||
return { meta, config: config ?? null, configured: true } as BotChannelItem
|
||||
@@ -194,10 +194,10 @@ const selectedItem = computed(() =>
|
||||
allChannels.value.find((c) => c.meta.type === selectedType.value) ?? null,
|
||||
)
|
||||
|
||||
watch(allChannels, (list) => {
|
||||
if (list.length === 0) {
|
||||
selectedType.value = null
|
||||
return
|
||||
watch(configuredChannels, (list) => {
|
||||
const first = list[0]
|
||||
if (first && !selectedType.value) {
|
||||
selectedType.value = first.meta.type ?? null
|
||||
}
|
||||
|
||||
const current = selectedType.value
|
||||
@@ -213,5 +213,4 @@ function addChannel(type: string) {
|
||||
addPopoverOpen.value = false
|
||||
selectedType.value = type
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
@@ -86,7 +86,16 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
<!-- WeChat QR Login -->
|
||||
<div v-if="channelItem.meta.type === 'weixin'">
|
||||
<WeixinQrLogin
|
||||
:bot-id="botId"
|
||||
@login-success="handleWeixinLoginSuccess"
|
||||
/>
|
||||
<Separator class="mt-4" />
|
||||
</div>
|
||||
|
||||
<Separator v-else />
|
||||
|
||||
<!-- Credentials form (dynamic from config_schema) -->
|
||||
<div class="space-y-4">
|
||||
@@ -259,6 +268,7 @@ import type { HandlersChannelMeta, ChannelChannelConfig, ChannelFieldSchema, Cha
|
||||
import { client } from '@memoh/sdk/client'
|
||||
import ConfirmPopover from '@/components/confirm-popover/index.vue'
|
||||
import ChannelIcon from '@/components/channel-icon/index.vue'
|
||||
import WeixinQrLogin from './weixin-qr-login.vue'
|
||||
|
||||
interface BotChannelItem {
|
||||
meta: HandlersChannelMeta
|
||||
@@ -571,6 +581,11 @@ function isAbsoluteHttpUrl(value: string): boolean {
|
||||
return /^https?:\/\//i.test(value)
|
||||
}
|
||||
|
||||
function handleWeixinLoginSuccess() {
|
||||
queryCache.invalidateQueries({ key: ['bot-channels', botIdRef.value] })
|
||||
emit('saved')
|
||||
}
|
||||
|
||||
async function copyWebhookCallback() {
|
||||
if (!webhookCallbackUrl.value) return
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,292 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h4 class="text-sm font-medium">
|
||||
{{ $t('bots.channels.weixinQr.title') }}
|
||||
</h4>
|
||||
<p class="text-xs text-muted-foreground mt-1">
|
||||
{{ $t('bots.channels.weixinQr.description') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- QR code display -->
|
||||
<div
|
||||
v-if="qrState === 'idle'"
|
||||
class="flex flex-col items-center gap-3 py-4"
|
||||
>
|
||||
<Button
|
||||
:disabled="isStarting"
|
||||
@click="startLogin"
|
||||
>
|
||||
<Spinner
|
||||
v-if="isStarting"
|
||||
class="mr-1.5"
|
||||
/>
|
||||
<FontAwesomeIcon
|
||||
v-else
|
||||
:icon="['fas', 'qrcode']"
|
||||
class="mr-1.5 size-3.5"
|
||||
/>
|
||||
{{ $t('bots.channels.weixinQr.startScan') }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="qrState === 'showing'"
|
||||
class="flex flex-col items-center gap-4 py-4"
|
||||
>
|
||||
<div class="relative rounded-lg border bg-white p-3">
|
||||
<img
|
||||
v-if="qrImageDataUrl"
|
||||
:src="qrImageDataUrl"
|
||||
alt="WeChat QR Code"
|
||||
class="size-52"
|
||||
>
|
||||
<div
|
||||
v-else
|
||||
class="size-52 flex items-center justify-center text-muted-foreground"
|
||||
>
|
||||
<Spinner />
|
||||
</div>
|
||||
|
||||
<!-- Overlay for scanned state -->
|
||||
<div
|
||||
v-if="pollStatus === 'scaned'"
|
||||
class="absolute inset-0 flex items-center justify-center rounded-lg bg-background/80"
|
||||
>
|
||||
<div class="text-center">
|
||||
<FontAwesomeIcon
|
||||
:icon="['fas', 'mobile-screen']"
|
||||
class="size-8 text-primary mb-2"
|
||||
/>
|
||||
<p class="text-sm font-medium text-foreground">
|
||||
{{ $t('bots.channels.weixinQr.scanned') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Overlay for expired state -->
|
||||
<div
|
||||
v-if="pollStatus === 'expired'"
|
||||
class="absolute inset-0 flex flex-col items-center justify-center rounded-lg bg-background/80 gap-2"
|
||||
>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{{ $t('bots.channels.weixinQr.expired') }}
|
||||
</p>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
@click="startLogin"
|
||||
>
|
||||
{{ $t('bots.channels.weixinQr.refresh') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-sm text-muted-foreground text-center max-w-xs">
|
||||
{{ statusText }}
|
||||
</p>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@click="cancel"
|
||||
>
|
||||
{{ $t('common.cancel') }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="qrState === 'success'"
|
||||
class="flex flex-col items-center gap-3 py-4"
|
||||
>
|
||||
<div class="flex size-12 items-center justify-center rounded-full bg-green-100 dark:bg-green-900">
|
||||
<FontAwesomeIcon
|
||||
:icon="['fas', 'check']"
|
||||
class="size-5 text-green-600 dark:text-green-400"
|
||||
/>
|
||||
</div>
|
||||
<p class="text-sm font-medium">
|
||||
{{ $t('bots.channels.weixinQr.success') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="qrState === 'error'"
|
||||
class="flex flex-col items-center gap-3 py-4"
|
||||
>
|
||||
<p class="text-sm text-destructive">
|
||||
{{ errorMessage }}
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@click="startLogin"
|
||||
>
|
||||
{{ $t('bots.channels.weixinQr.retry') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onUnmounted } from 'vue'
|
||||
import { Button, Spinner } from '@memoh/ui'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { toast } from 'vue-sonner'
|
||||
import { client } from '@memoh/sdk/client'
|
||||
import QRCode from 'qrcode'
|
||||
|
||||
const props = defineProps<{
|
||||
botId: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
loginSuccess: []
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
type QRState = 'idle' | 'showing' | 'success' | 'error'
|
||||
|
||||
const qrState = ref<QRState>('idle')
|
||||
const qrCode = ref('')
|
||||
const qrImageDataUrl = ref('')
|
||||
const pollStatus = ref('')
|
||||
const isStarting = ref(false)
|
||||
const errorMessage = ref('')
|
||||
let pollTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let aborted = false
|
||||
|
||||
const statusText = computed(() => {
|
||||
switch (pollStatus.value) {
|
||||
case 'wait':
|
||||
return t('bots.channels.weixinQr.waitingScan')
|
||||
case 'scaned':
|
||||
return t('bots.channels.weixinQr.scanned')
|
||||
case 'expired':
|
||||
return t('bots.channels.weixinQr.expired')
|
||||
default:
|
||||
return t('bots.channels.weixinQr.waitingScan')
|
||||
}
|
||||
})
|
||||
|
||||
async function startLogin() {
|
||||
aborted = false
|
||||
isStarting.value = true
|
||||
errorMessage.value = ''
|
||||
pollStatus.value = ''
|
||||
qrImageDataUrl.value = ''
|
||||
|
||||
try {
|
||||
const baseUrl = client.getConfig().baseUrl || ''
|
||||
const resp = await fetch(`${baseUrl}/bots/${encodeURIComponent(props.botId)}/channel/weixin/qr/start`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${localStorage.getItem('token') || ''}`,
|
||||
},
|
||||
body: JSON.stringify({}),
|
||||
})
|
||||
|
||||
if (!resp.ok) {
|
||||
const body = await resp.text()
|
||||
throw new Error(body || `HTTP ${resp.status}`)
|
||||
}
|
||||
|
||||
const data = await resp.json() as { qr_code_url: string; qr_code: string; message: string }
|
||||
const qrContent = data.qr_code_url || data.qr_code || ''
|
||||
if (!qrContent) {
|
||||
throw new Error('No QR code data returned')
|
||||
}
|
||||
|
||||
qrCode.value = data.qr_code || ''
|
||||
qrImageDataUrl.value = await QRCode.toDataURL(qrContent, { width: 208, margin: 1 })
|
||||
qrState.value = 'showing'
|
||||
|
||||
startPolling()
|
||||
} catch (err) {
|
||||
errorMessage.value = err instanceof Error ? err.message : String(err)
|
||||
qrState.value = 'error'
|
||||
} finally {
|
||||
isStarting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function startPolling() {
|
||||
if (aborted) return
|
||||
pollOnce()
|
||||
}
|
||||
|
||||
async function pollOnce() {
|
||||
if (aborted || qrState.value !== 'showing') return
|
||||
|
||||
try {
|
||||
const baseUrl = client.getConfig().baseUrl || ''
|
||||
const resp = await fetch(`${baseUrl}/bots/${encodeURIComponent(props.botId)}/channel/weixin/qr/poll`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${localStorage.getItem('token') || ''}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
qr_code: qrCode.value,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!resp.ok) {
|
||||
const body = await resp.text()
|
||||
throw new Error(body || `HTTP ${resp.status}`)
|
||||
}
|
||||
|
||||
const data = await resp.json() as { status: string; message: string }
|
||||
pollStatus.value = data.status
|
||||
|
||||
switch (data.status) {
|
||||
case 'confirmed':
|
||||
qrState.value = 'success'
|
||||
toast.success(t('bots.channels.weixinQr.success'))
|
||||
emit('loginSuccess')
|
||||
return
|
||||
case 'expired':
|
||||
return
|
||||
case 'wait':
|
||||
case 'scaned':
|
||||
if (!aborted) {
|
||||
pollTimer = setTimeout(pollOnce, 1500)
|
||||
}
|
||||
return
|
||||
default:
|
||||
if (!aborted) {
|
||||
pollTimer = setTimeout(pollOnce, 2000)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
if (!aborted) {
|
||||
pollTimer = setTimeout(pollOnce, 3000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
aborted = true
|
||||
if (pollTimer) {
|
||||
clearTimeout(pollTimer)
|
||||
pollTimer = null
|
||||
}
|
||||
qrState.value = 'idle'
|
||||
qrCode.value = ''
|
||||
qrImageDataUrl.value = ''
|
||||
pollStatus.value = ''
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
aborted = true
|
||||
if (pollTimer) {
|
||||
clearTimeout(pollTimer)
|
||||
pollTimer = null
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Local channel icons under public/channels/.
|
||||
* getChannelImage: URL to local icon when available.
|
||||
* getChannelIcon: FontAwesome fallback when no local image.
|
||||
*/
|
||||
|
||||
const LOCAL_CHANNEL_IMAGES: Record<string, string> = {
|
||||
feishu: '/channels/feishu.png',
|
||||
matrix: '/channels/matrix.svg',
|
||||
telegram: '/channels/telegram.webp',
|
||||
}
|
||||
|
||||
const CHANNEL_ICONS: Record<string, [string, string]> = {
|
||||
qq: ['fab', 'qq'],
|
||||
telegram: ['fab', 'telegram'],
|
||||
matrix: ['fas', 'hashtag'],
|
||||
feishu: ['fas', 'comment-dots'],
|
||||
web: ['fas', 'globe'],
|
||||
slack: ['fab', 'slack'],
|
||||
discord: ['fab', 'discord'],
|
||||
email: ['fas', 'envelope'],
|
||||
}
|
||||
|
||||
const DEFAULT_ICON: [string, string] = ['far', 'comment']
|
||||
|
||||
export function getChannelIcon(platformKey: string): [string, string] {
|
||||
if (!platformKey) return DEFAULT_ICON
|
||||
return CHANNEL_ICONS[platformKey] ?? DEFAULT_ICON
|
||||
}
|
||||
|
||||
export function getChannelImage(platformKey: string): string | null {
|
||||
if (!platformKey) return null
|
||||
return LOCAL_CHANNEL_IMAGES[platformKey] ?? null
|
||||
}
|
||||
@@ -36,6 +36,7 @@ import (
|
||||
"github.com/memohai/memoh/internal/channel/adapters/qq"
|
||||
"github.com/memohai/memoh/internal/channel/adapters/telegram"
|
||||
"github.com/memohai/memoh/internal/channel/adapters/wecom"
|
||||
"github.com/memohai/memoh/internal/channel/adapters/weixin"
|
||||
"github.com/memohai/memoh/internal/channel/identities"
|
||||
"github.com/memohai/memoh/internal/channel/inbound"
|
||||
"github.com/memohai/memoh/internal/channel/route"
|
||||
@@ -236,6 +237,7 @@ func runServe() {
|
||||
provideServerHandler(handlers.NewCompactionHandler),
|
||||
provideServerHandler(handlers.NewChannelHandler),
|
||||
provideServerHandler(feishu.NewWebhookServerHandler),
|
||||
provideServerHandler(weixin.NewQRServerHandler),
|
||||
provideServerHandler(provideUsersHandler),
|
||||
provideServerHandler(handlers.NewMemoryProvidersHandler),
|
||||
provideServerHandler(handlers.NewTtsProvidersHandler),
|
||||
@@ -482,6 +484,9 @@ func provideChannelRegistry(log *slog.Logger, hub *local.RouteHub, mediaService
|
||||
feishuAdapter.SetAssetOpener(mediaService)
|
||||
registry.MustRegister(feishuAdapter)
|
||||
registry.MustRegister(wecom.NewWeComAdapter(log))
|
||||
weixinAdapter := weixin.NewWeixinAdapter(log)
|
||||
weixinAdapter.SetAssetOpener(mediaService)
|
||||
registry.MustRegister(weixinAdapter)
|
||||
registry.MustRegister(local.NewCLIAdapter(hub))
|
||||
registry.MustRegister(local.NewWebAdapter(hub))
|
||||
return registry
|
||||
|
||||
@@ -37,6 +37,7 @@ import (
|
||||
"github.com/memohai/memoh/internal/channel/adapters/qq"
|
||||
"github.com/memohai/memoh/internal/channel/adapters/telegram"
|
||||
"github.com/memohai/memoh/internal/channel/adapters/wecom"
|
||||
"github.com/memohai/memoh/internal/channel/adapters/weixin"
|
||||
"github.com/memohai/memoh/internal/channel/identities"
|
||||
"github.com/memohai/memoh/internal/channel/inbound"
|
||||
"github.com/memohai/memoh/internal/channel/route"
|
||||
@@ -162,6 +163,7 @@ func runServe() {
|
||||
provideServerHandler(handlers.NewCompactionHandler),
|
||||
provideServerHandler(handlers.NewChannelHandler),
|
||||
provideServerHandler(feishu.NewWebhookServerHandler),
|
||||
provideServerHandler(weixin.NewQRServerHandler),
|
||||
provideServerHandler(provideUsersHandler),
|
||||
provideServerHandler(handlers.NewMemoryProvidersHandler),
|
||||
provideServerHandler(handlers.NewTtsProvidersHandler),
|
||||
@@ -396,6 +398,9 @@ func provideChannelRegistry(log *slog.Logger, hub *local.RouteHub, mediaService
|
||||
feishuAdapter.SetAssetOpener(mediaService)
|
||||
registry.MustRegister(feishuAdapter)
|
||||
registry.MustRegister(wecom.NewWeComAdapter(log))
|
||||
weixinAdapter := weixin.NewWeixinAdapter(log)
|
||||
weixinAdapter.SetAssetOpener(mediaService)
|
||||
registry.MustRegister(weixinAdapter)
|
||||
registry.MustRegister(local.NewCLIAdapter(hub))
|
||||
registry.MustRegister(local.NewWebAdapter(hub))
|
||||
return registry
|
||||
|
||||
@@ -114,7 +114,7 @@ func (s *Service) SearchAccounts(ctx context.Context, query string, limit int) (
|
||||
}
|
||||
rows, err := s.queries.SearchAccounts(ctx, sqlc.SearchAccountsParams{
|
||||
Query: strings.TrimSpace(query),
|
||||
LimitCount: int32(limit),
|
||||
LimitCount: int32(limit), //nolint:gosec // limit is capped above
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
This WeChat (Weixin) channel adapter for Memoh is derived from the
|
||||
OpenClaw WeChat plugin ("@tencent-weixin/openclaw-weixin"), which is
|
||||
licensed under the MIT License. The protocol logic, API structures,
|
||||
CDN encryption scheme, and QR login flow were ported from that
|
||||
TypeScript codebase to Go.
|
||||
|
||||
The original MIT license text is reproduced below, as required by its
|
||||
terms.
|
||||
|
||||
------------------------------------------------------------------------
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Tencent Inc.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,267 @@
|
||||
// Derived from @tencent-weixin/openclaw-weixin (MIT License, Copyright (c) 2026 Tencent Inc.)
|
||||
// See LICENSE in this directory for the full license text.
|
||||
|
||||
package weixin
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
channelVersion = "1.0.0"
|
||||
defaultLongPollTimeout = 35 * time.Second
|
||||
defaultAPITimeout = 15 * time.Second
|
||||
defaultConfigTimeout = 10 * time.Second
|
||||
|
||||
sessionExpiredErrCode = -14
|
||||
)
|
||||
|
||||
// Client talks to the Tencent iLink WeChat bot API.
|
||||
type Client struct {
|
||||
httpClient *http.Client
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
// NewClient creates a WeChat API client.
|
||||
func NewClient(log *slog.Logger) *Client {
|
||||
if log == nil {
|
||||
log = slog.Default()
|
||||
}
|
||||
return &Client{
|
||||
httpClient: &http.Client{Timeout: 0}, // per-request timeout via context
|
||||
logger: log.With(slog.String("component", "weixin_client")),
|
||||
}
|
||||
}
|
||||
|
||||
func buildBaseInfo() BaseInfo {
|
||||
return BaseInfo{ChannelVersion: channelVersion}
|
||||
}
|
||||
|
||||
// randomWechatUIN generates the X-WECHAT-UIN header value: random uint32 -> decimal -> base64.
|
||||
func randomWechatUIN() string {
|
||||
var buf [4]byte
|
||||
_, _ = rand.Read(buf[:])
|
||||
n := binary.BigEndian.Uint32(buf[:])
|
||||
return base64.StdEncoding.EncodeToString([]byte(strconv.FormatUint(uint64(n), 10)))
|
||||
}
|
||||
|
||||
func (*Client) buildHeaders(token string, bodyLen int) http.Header {
|
||||
h := http.Header{}
|
||||
h.Set("Content-Type", "application/json")
|
||||
h.Set("AuthorizationType", "ilink_bot_token")
|
||||
h.Set("Content-Length", strconv.Itoa(bodyLen))
|
||||
h.Set("X-WECHAT-UIN", randomWechatUIN())
|
||||
if strings.TrimSpace(token) != "" {
|
||||
h.Set("Authorization", "Bearer "+strings.TrimSpace(token))
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
func ensureTrailingSlash(u string) string {
|
||||
if strings.HasSuffix(u, "/") {
|
||||
return u
|
||||
}
|
||||
return u + "/"
|
||||
}
|
||||
|
||||
func (c *Client) apiPost(ctx context.Context, baseURL, endpoint string, body []byte, token string, timeout time.Duration) ([]byte, error) {
|
||||
base := ensureTrailingSlash(baseURL)
|
||||
u, err := url.JoinPath(base, endpoint)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("weixin api url: %w", err)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, u, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("weixin api request: %w", err)
|
||||
}
|
||||
for k, vs := range c.buildHeaders(token, len(body)) {
|
||||
for _, v := range vs {
|
||||
req.Header.Add(k, v)
|
||||
}
|
||||
}
|
||||
|
||||
resp, err := c.httpClient.Do(req) //nolint:gosec // URL is constructed from trusted admin-configured baseURL
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("weixin api fetch: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
raw, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("weixin api read: %w", err)
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("weixin api %s %d: %s", endpoint, resp.StatusCode, string(raw))
|
||||
}
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
// GetUpdates performs a long-poll request to receive new messages.
|
||||
func (c *Client) GetUpdates(ctx context.Context, cfg adapterConfig, getUpdatesBuf string) (*GetUpdatesResponse, error) {
|
||||
timeout := defaultLongPollTimeout
|
||||
if cfg.PollTimeoutSeconds > 0 {
|
||||
timeout = time.Duration(cfg.PollTimeoutSeconds) * time.Second
|
||||
}
|
||||
body, err := json.Marshal(GetUpdatesRequest{
|
||||
GetUpdatesBuf: getUpdatesBuf,
|
||||
BaseInfo: buildBaseInfo(),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
raw, err := c.apiPost(ctx, cfg.BaseURL, "ilink/bot/getupdates", body, cfg.Token, timeout+5*time.Second)
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return &GetUpdatesResponse{Ret: 0, Msgs: nil, GetUpdatesBuf: getUpdatesBuf}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
var resp GetUpdatesResponse
|
||||
if err := json.Unmarshal(raw, &resp); err != nil {
|
||||
return nil, fmt.Errorf("weixin getupdates decode: %w", err)
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// SendMessage sends a text or media message downstream.
|
||||
func (c *Client) SendMessage(ctx context.Context, cfg adapterConfig, msg SendMessageRequest) error {
|
||||
msg.BaseInfo = buildBaseInfo()
|
||||
body, err := json.Marshal(msg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = c.apiPost(ctx, cfg.BaseURL, "ilink/bot/sendmessage", body, cfg.Token, defaultAPITimeout)
|
||||
return err
|
||||
}
|
||||
|
||||
// GetConfig fetches bot config (typing_ticket etc.).
|
||||
func (c *Client) GetConfig(ctx context.Context, cfg adapterConfig, userID, contextToken string) (*GetConfigResponse, error) {
|
||||
body, err := json.Marshal(GetConfigRequest{
|
||||
ILinkUserID: userID,
|
||||
ContextToken: contextToken,
|
||||
BaseInfo: buildBaseInfo(),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
raw, err := c.apiPost(ctx, cfg.BaseURL, "ilink/bot/getconfig", body, cfg.Token, defaultConfigTimeout)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var resp GetConfigResponse
|
||||
if err := json.Unmarshal(raw, &resp); err != nil {
|
||||
return nil, fmt.Errorf("weixin getconfig decode: %w", err)
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// SendTyping sends or cancels the typing indicator.
|
||||
func (c *Client) SendTyping(ctx context.Context, cfg adapterConfig, userID, typingTicket string, status int) error {
|
||||
body, err := json.Marshal(SendTypingRequest{
|
||||
ILinkUserID: userID,
|
||||
TypingTicket: typingTicket,
|
||||
Status: status,
|
||||
BaseInfo: buildBaseInfo(),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = c.apiPost(ctx, cfg.BaseURL, "ilink/bot/sendtyping", body, cfg.Token, defaultConfigTimeout)
|
||||
return err
|
||||
}
|
||||
|
||||
// GetUploadURL requests a CDN pre-signed upload URL.
|
||||
func (c *Client) GetUploadURL(ctx context.Context, cfg adapterConfig, req GetUploadURLRequest) (*GetUploadURLResponse, error) {
|
||||
req.BaseInfo = buildBaseInfo()
|
||||
body, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
raw, err := c.apiPost(ctx, cfg.BaseURL, "ilink/bot/getuploadurl", body, cfg.Token, defaultAPITimeout)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var resp GetUploadURLResponse
|
||||
if err := json.Unmarshal(raw, &resp); err != nil {
|
||||
return nil, fmt.Errorf("weixin getuploadurl decode: %w", err)
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// FetchQRCode requests a new QR code for login.
|
||||
func (c *Client) FetchQRCode(ctx context.Context, apiBaseURL string) (*QRCodeResponse, error) {
|
||||
base := ensureTrailingSlash(apiBaseURL)
|
||||
u := base + "ilink/bot/get_bot_qrcode?bot_type=" + url.QueryEscape(defaultBotType)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := c.httpClient.Do(req) //nolint:gosec // URL from admin-configured baseURL
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("weixin qrcode fetch: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
raw, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("weixin qrcode %d: %s", resp.StatusCode, string(raw))
|
||||
}
|
||||
var qr QRCodeResponse
|
||||
if err := json.Unmarshal(raw, &qr); err != nil {
|
||||
return nil, fmt.Errorf("weixin qrcode decode: %w", err)
|
||||
}
|
||||
return &qr, nil
|
||||
}
|
||||
|
||||
// PollQRStatus long-polls the QR code login status.
|
||||
func (c *Client) PollQRStatus(ctx context.Context, apiBaseURL, qrcode string) (*QRStatusResponse, error) {
|
||||
base := ensureTrailingSlash(apiBaseURL)
|
||||
u := base + "ilink/bot/get_qrcode_status?qrcode=" + url.QueryEscape(qrcode)
|
||||
|
||||
ctx, cancel := context.WithTimeout(ctx, 35*time.Second)
|
||||
defer cancel()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("iLink-App-ClientVersion", "1")
|
||||
resp, err := c.httpClient.Do(req) //nolint:gosec // URL from admin-configured baseURL
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return &QRStatusResponse{Status: "wait"}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("weixin qrstatus fetch: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
raw, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("weixin qrstatus %d: %s", resp.StatusCode, string(raw))
|
||||
}
|
||||
var status QRStatusResponse
|
||||
if err := json.Unmarshal(raw, &status); err != nil {
|
||||
return nil, fmt.Errorf("weixin qrstatus decode: %w", err)
|
||||
}
|
||||
return &status, nil
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
package weixin
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/memohai/memoh/internal/channel"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultBaseURL = "https://ilinkai.weixin.qq.com"
|
||||
defaultCDNBaseURL = "https://novac2c.cdn.weixin.qq.com/c2c"
|
||||
defaultBotType = "3"
|
||||
)
|
||||
|
||||
type adapterConfig struct {
|
||||
Token string
|
||||
BaseURL string
|
||||
CDNBaseURL string
|
||||
PollTimeoutSeconds int
|
||||
EnableTyping bool
|
||||
}
|
||||
|
||||
func normalizeConfig(raw map[string]any) (map[string]any, error) {
|
||||
cfg, err := parseConfig(raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := map[string]any{
|
||||
"token": cfg.Token,
|
||||
"baseUrl": cfg.BaseURL,
|
||||
}
|
||||
if cfg.PollTimeoutSeconds > 0 {
|
||||
out["pollTimeoutSeconds"] = cfg.PollTimeoutSeconds
|
||||
}
|
||||
if cfg.EnableTyping {
|
||||
out["enableTyping"] = true
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func parseConfig(raw map[string]any) (adapterConfig, error) {
|
||||
cfg := adapterConfig{
|
||||
Token: strings.TrimSpace(channel.ReadString(raw, "token")),
|
||||
BaseURL: strings.TrimSpace(channel.ReadString(raw, "baseUrl", "base_url")),
|
||||
CDNBaseURL: defaultCDNBaseURL,
|
||||
}
|
||||
if cfg.BaseURL == "" {
|
||||
cfg.BaseURL = defaultBaseURL
|
||||
}
|
||||
if v, ok := readInt(raw, "pollTimeoutSeconds", "poll_timeout_seconds"); ok {
|
||||
cfg.PollTimeoutSeconds = v
|
||||
}
|
||||
if v, ok := readBool(raw, "enableTyping", "enable_typing"); ok {
|
||||
cfg.EnableTyping = v
|
||||
}
|
||||
if cfg.Token == "" {
|
||||
return adapterConfig{}, errors.New("weixin token is required")
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func normalizeTarget(raw string) string {
|
||||
v := strings.TrimSpace(raw)
|
||||
if v == "" {
|
||||
return ""
|
||||
}
|
||||
v = strings.TrimPrefix(v, "weixin:")
|
||||
return strings.TrimSpace(v)
|
||||
}
|
||||
|
||||
func normalizeUserConfig(raw map[string]any) (map[string]any, error) {
|
||||
userID := strings.TrimSpace(channel.ReadString(raw, "userId", "user_id"))
|
||||
if userID == "" {
|
||||
return nil, errors.New("weixin user_id is required")
|
||||
}
|
||||
return map[string]any{"user_id": userID}, nil
|
||||
}
|
||||
|
||||
func resolveTarget(raw map[string]any) (string, error) {
|
||||
userID := strings.TrimSpace(channel.ReadString(raw, "userId", "user_id"))
|
||||
if userID == "" {
|
||||
return "", errors.New("weixin user config requires user_id")
|
||||
}
|
||||
return userID, nil
|
||||
}
|
||||
|
||||
func matchBinding(config map[string]any, criteria channel.BindingCriteria) bool {
|
||||
userID := strings.TrimSpace(channel.ReadString(config, "userId", "user_id"))
|
||||
if userID == "" {
|
||||
return false
|
||||
}
|
||||
if criteria.SubjectID != "" && criteria.SubjectID == userID {
|
||||
return true
|
||||
}
|
||||
if v := strings.TrimSpace(criteria.Attribute("user_id")); v != "" && v == userID {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func buildUserConfig(identity channel.Identity) map[string]any {
|
||||
out := map[string]any{}
|
||||
if v := strings.TrimSpace(identity.SubjectID); v != "" {
|
||||
out["user_id"] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func readInt(raw map[string]any, keys ...string) (int, bool) {
|
||||
for _, key := range keys {
|
||||
value, ok := raw[key]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
switch v := value.(type) {
|
||||
case int:
|
||||
return v, true
|
||||
case int32:
|
||||
return int(v), true
|
||||
case int64:
|
||||
return int(v), true
|
||||
case float64:
|
||||
return int(v), true
|
||||
case float32:
|
||||
return int(v), true
|
||||
case string:
|
||||
trimmed := strings.TrimSpace(v)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
parsed, err := strconv.Atoi(trimmed)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
return parsed, true
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func readBool(raw map[string]any, keys ...string) (bool, bool) {
|
||||
for _, key := range keys {
|
||||
value, ok := raw[key]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
switch v := value.(type) {
|
||||
case bool:
|
||||
return v, true
|
||||
case string:
|
||||
b, err := strconv.ParseBool(strings.TrimSpace(v))
|
||||
if err == nil {
|
||||
return b, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false, false
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package weixin
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/memohai/memoh/internal/channel"
|
||||
)
|
||||
|
||||
func TestParseConfig(t *testing.T) {
|
||||
t.Run("valid minimal", func(t *testing.T) {
|
||||
cfg, err := parseConfig(map[string]any{"token": "abc123"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if cfg.Token != "abc123" {
|
||||
t.Errorf("token = %q, want %q", cfg.Token, "abc123")
|
||||
}
|
||||
if cfg.BaseURL != defaultBaseURL {
|
||||
t.Errorf("baseURL = %q, want %q", cfg.BaseURL, defaultBaseURL)
|
||||
}
|
||||
if cfg.CDNBaseURL != defaultCDNBaseURL {
|
||||
t.Errorf("cdnBaseURL = %q, want %q", cfg.CDNBaseURL, defaultCDNBaseURL)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("valid full", func(t *testing.T) {
|
||||
cfg, err := parseConfig(map[string]any{
|
||||
"token": "tok",
|
||||
"baseUrl": "https://example.com",
|
||||
"pollTimeoutSeconds": 60,
|
||||
"enableTyping": true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if cfg.BaseURL != "https://example.com" {
|
||||
t.Errorf("baseURL = %q", cfg.BaseURL)
|
||||
}
|
||||
if cfg.CDNBaseURL != defaultCDNBaseURL {
|
||||
t.Errorf("cdnBaseURL = %q, want %q", cfg.CDNBaseURL, defaultCDNBaseURL)
|
||||
}
|
||||
if cfg.PollTimeoutSeconds != 60 {
|
||||
t.Errorf("pollTimeout = %d", cfg.PollTimeoutSeconds)
|
||||
}
|
||||
if !cfg.EnableTyping {
|
||||
t.Error("enableTyping should be true")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missing token", func(t *testing.T) {
|
||||
_, err := parseConfig(map[string]any{"baseUrl": "https://example.com"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing token")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("snake_case keys", func(t *testing.T) {
|
||||
cfg, err := parseConfig(map[string]any{
|
||||
"token": "tok",
|
||||
"base_url": "https://alt.com",
|
||||
"poll_timeout_seconds": 45,
|
||||
"enable_typing": true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if cfg.BaseURL != "https://alt.com" {
|
||||
t.Errorf("baseURL = %q", cfg.BaseURL)
|
||||
}
|
||||
if cfg.CDNBaseURL != defaultCDNBaseURL {
|
||||
t.Errorf("cdnBaseURL = %q", cfg.CDNBaseURL)
|
||||
}
|
||||
if cfg.PollTimeoutSeconds != 45 {
|
||||
t.Errorf("pollTimeout = %d", cfg.PollTimeoutSeconds)
|
||||
}
|
||||
if !cfg.EnableTyping {
|
||||
t.Error("enableTyping should be true")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestNormalizeConfig(t *testing.T) {
|
||||
out, err := normalizeConfig(map[string]any{
|
||||
"token": "tok",
|
||||
"baseUrl": "https://example.com",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if out["token"] != "tok" {
|
||||
t.Errorf("token = %v", out["token"])
|
||||
}
|
||||
if out["baseUrl"] != "https://example.com" {
|
||||
t.Errorf("baseUrl = %v", out["baseUrl"])
|
||||
}
|
||||
if _, has := out["cdnBaseUrl"]; has {
|
||||
t.Error("cdnBaseUrl should not be in normalized output")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeTarget(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{"abc@im.wechat", "abc@im.wechat"},
|
||||
{"weixin:abc@im.wechat", "abc@im.wechat"},
|
||||
{" weixin: abc@im.wechat ", "abc@im.wechat"},
|
||||
{"", ""},
|
||||
{" ", ""},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
got := normalizeTarget(tc.input)
|
||||
if got != tc.want {
|
||||
t.Errorf("normalizeTarget(%q) = %q, want %q", tc.input, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeUserConfig(t *testing.T) {
|
||||
t.Run("valid", func(t *testing.T) {
|
||||
out, err := normalizeUserConfig(map[string]any{"user_id": "u1"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if out["user_id"] != "u1" {
|
||||
t.Errorf("user_id = %v", out["user_id"])
|
||||
}
|
||||
})
|
||||
t.Run("missing", func(t *testing.T) {
|
||||
_, err := normalizeUserConfig(map[string]any{})
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestResolveTarget(t *testing.T) {
|
||||
target, err := resolveTarget(map[string]any{"user_id": "u1"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if target != "u1" {
|
||||
t.Errorf("target = %q", target)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchBinding(t *testing.T) {
|
||||
config := map[string]any{"user_id": "u1"}
|
||||
if !matchBinding(config, channel.BindingCriteria{SubjectID: "u1"}) {
|
||||
t.Error("should match by subject_id")
|
||||
}
|
||||
if matchBinding(config, channel.BindingCriteria{SubjectID: "u2"}) {
|
||||
t.Error("should not match different subject_id")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildUserConfig(t *testing.T) {
|
||||
id := channel.Identity{SubjectID: "u1"}
|
||||
out := buildUserConfig(id)
|
||||
if out["user_id"] != "u1" {
|
||||
t.Errorf("user_id = %v", out["user_id"])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package weixin
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// contextTokenCache stores the latest context_token per target user.
|
||||
// The WeChat API requires a context_token (issued per inbound message)
|
||||
// for every outbound send. This cache is populated by the long-poll
|
||||
// receiver and read by the sender, similar to WeCom's callbackContextCache.
|
||||
type contextTokenCache struct {
|
||||
mu sync.RWMutex
|
||||
items map[string]contextTokenEntry
|
||||
ttl time.Duration
|
||||
}
|
||||
|
||||
type contextTokenEntry struct {
|
||||
Token string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
func newContextTokenCache(ttl time.Duration) *contextTokenCache {
|
||||
if ttl <= 0 {
|
||||
ttl = 24 * time.Hour
|
||||
}
|
||||
return &contextTokenCache{
|
||||
items: make(map[string]contextTokenEntry),
|
||||
ttl: ttl,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *contextTokenCache) Put(target string, token string) {
|
||||
key := strings.TrimSpace(target)
|
||||
if key == "" || strings.TrimSpace(token) == "" {
|
||||
return
|
||||
}
|
||||
c.mu.Lock()
|
||||
c.items[key] = contextTokenEntry{
|
||||
Token: token,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
c.gcLocked()
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
func (c *contextTokenCache) Get(target string) (string, bool) {
|
||||
key := strings.TrimSpace(target)
|
||||
if key == "" {
|
||||
return "", false
|
||||
}
|
||||
c.mu.RLock()
|
||||
entry, ok := c.items[key]
|
||||
c.mu.RUnlock()
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
if time.Since(entry.CreatedAt) > c.ttl {
|
||||
c.mu.Lock()
|
||||
delete(c.items, key)
|
||||
c.mu.Unlock()
|
||||
return "", false
|
||||
}
|
||||
return entry.Token, true
|
||||
}
|
||||
|
||||
func (c *contextTokenCache) gcLocked() {
|
||||
if len(c.items) < 512 {
|
||||
return
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
for key, entry := range c.items {
|
||||
if now.Sub(entry.CreatedAt) > c.ttl {
|
||||
delete(c.items, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package weixin
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestContextTokenCache_PutGet(t *testing.T) {
|
||||
cache := newContextTokenCache(1 * time.Hour)
|
||||
|
||||
cache.Put("user1", "token1")
|
||||
got, ok := cache.Get("user1")
|
||||
if !ok {
|
||||
t.Fatal("expected to find token")
|
||||
}
|
||||
if got != "token1" {
|
||||
t.Errorf("token = %q, want %q", got, "token1")
|
||||
}
|
||||
}
|
||||
|
||||
func TestContextTokenCache_Miss(t *testing.T) {
|
||||
cache := newContextTokenCache(1 * time.Hour)
|
||||
|
||||
_, ok := cache.Get("nonexistent")
|
||||
if ok {
|
||||
t.Error("expected miss for nonexistent key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestContextTokenCache_EmptyKey(t *testing.T) {
|
||||
cache := newContextTokenCache(1 * time.Hour)
|
||||
|
||||
cache.Put("", "token1")
|
||||
_, ok := cache.Get("")
|
||||
if ok {
|
||||
t.Error("expected miss for empty key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestContextTokenCache_EmptyToken(t *testing.T) {
|
||||
cache := newContextTokenCache(1 * time.Hour)
|
||||
|
||||
cache.Put("user1", "")
|
||||
_, ok := cache.Get("user1")
|
||||
if ok {
|
||||
t.Error("expected miss for empty token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestContextTokenCache_Overwrite(t *testing.T) {
|
||||
cache := newContextTokenCache(1 * time.Hour)
|
||||
|
||||
cache.Put("user1", "token1")
|
||||
cache.Put("user1", "token2")
|
||||
got, ok := cache.Get("user1")
|
||||
if !ok {
|
||||
t.Fatal("expected to find token")
|
||||
}
|
||||
if got != "token2" {
|
||||
t.Errorf("token = %q, want %q", got, "token2")
|
||||
}
|
||||
}
|
||||
|
||||
func TestContextTokenCache_Expiry(t *testing.T) {
|
||||
cache := newContextTokenCache(1 * time.Millisecond)
|
||||
|
||||
cache.Put("user1", "token1")
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
_, ok := cache.Get("user1")
|
||||
if ok {
|
||||
t.Error("expected miss after expiry")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
// Derived from @tencent-weixin/openclaw-weixin (MIT License, Copyright (c) 2026 Tencent Inc.)
|
||||
// See LICENSE in this directory for the full license text.
|
||||
|
||||
package weixin
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// encryptAESECB encrypts plaintext with AES-128-ECB and PKCS7 padding.
|
||||
func encryptAESECB(plaintext, key []byte) ([]byte, error) {
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
bs := block.BlockSize()
|
||||
padded := pkcs7Pad(plaintext, bs)
|
||||
out := make([]byte, len(padded))
|
||||
for i := 0; i < len(padded); i += bs {
|
||||
block.Encrypt(out[i:i+bs], padded[i:i+bs])
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// decryptAESECB decrypts ciphertext with AES-128-ECB and PKCS7 padding.
|
||||
func decryptAESECB(ciphertext, key []byte) ([]byte, error) {
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
bs := block.BlockSize()
|
||||
if len(ciphertext)%bs != 0 {
|
||||
return nil, fmt.Errorf("ciphertext length %d is not a multiple of block size %d", len(ciphertext), bs)
|
||||
}
|
||||
out := make([]byte, len(ciphertext))
|
||||
for i := 0; i < len(ciphertext); i += bs {
|
||||
block.Decrypt(out[i:i+bs], ciphertext[i:i+bs])
|
||||
}
|
||||
return pkcs7Unpad(out, bs)
|
||||
}
|
||||
|
||||
func pkcs7Pad(data []byte, blockSize int) []byte {
|
||||
padding := blockSize - len(data)%blockSize
|
||||
padded := make([]byte, len(data)+padding)
|
||||
copy(padded, data)
|
||||
for i := len(data); i < len(padded); i++ {
|
||||
padded[i] = byte(padding) //nolint:gosec // padding is always 1..blockSize(16)
|
||||
}
|
||||
return padded
|
||||
}
|
||||
|
||||
func pkcs7Unpad(data []byte, blockSize int) ([]byte, error) {
|
||||
if len(data) == 0 {
|
||||
return data, nil
|
||||
}
|
||||
padding := int(data[len(data)-1])
|
||||
if padding > blockSize || padding == 0 {
|
||||
return nil, fmt.Errorf("invalid pkcs7 padding %d", padding)
|
||||
}
|
||||
for i := len(data) - padding; i < len(data); i++ {
|
||||
if data[i] != byte(padding) { //nolint:gosec // padding is always 1..blockSize(16)
|
||||
return nil, fmt.Errorf("invalid pkcs7 padding at byte %d", i)
|
||||
}
|
||||
}
|
||||
return data[:len(data)-padding], nil
|
||||
}
|
||||
|
||||
// aesECBPaddedSize returns the ciphertext size after AES-128-ECB with PKCS7 padding.
|
||||
// PKCS7 always adds at least 1 byte of padding, rounding up to a 16-byte boundary.
|
||||
func aesECBPaddedSize(plaintextSize int) int {
|
||||
// ceil((n+1) / 16) * 16
|
||||
return ((plaintextSize + 1 + 15) / 16) * 16 //nolint:mnd
|
||||
}
|
||||
|
||||
// parseAESKey parses a base64-encoded AES key. Handles two formats:
|
||||
// - base64(raw 16 bytes)
|
||||
// - base64(hex string of 16 bytes) -> 32 hex chars.
|
||||
func parseAESKey(aesKeyBase64 string) ([]byte, error) {
|
||||
decoded, err := base64.StdEncoding.DecodeString(aesKeyBase64)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("aes key base64 decode: %w", err)
|
||||
}
|
||||
if len(decoded) == 16 {
|
||||
return decoded, nil
|
||||
}
|
||||
if len(decoded) == 32 {
|
||||
s := string(decoded)
|
||||
if isHexString(s) {
|
||||
key, err := hex.DecodeString(s)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("aes key hex decode: %w", err)
|
||||
}
|
||||
return key, nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("aes key must be 16 raw bytes or 32-char hex, got %d bytes", len(decoded))
|
||||
}
|
||||
|
||||
func isHexString(s string) bool {
|
||||
for _, c := range s {
|
||||
if (c < '0' || c > '9') && (c < 'a' || c > 'f') && (c < 'A' || c > 'F') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// CDN URL helpers.
|
||||
|
||||
func buildCDNDownloadURL(encryptedQueryParam, cdnBaseURL string) string {
|
||||
return cdnBaseURL + "/download?encrypted_query_param=" + url.QueryEscape(encryptedQueryParam)
|
||||
}
|
||||
|
||||
func buildCDNUploadURL(cdnBaseURL, uploadParam, filekey string) string {
|
||||
return cdnBaseURL + "/upload?encrypted_query_param=" + url.QueryEscape(uploadParam) +
|
||||
"&filekey=" + url.QueryEscape(filekey)
|
||||
}
|
||||
|
||||
// downloadAndDecrypt fetches encrypted bytes from the CDN and decrypts with AES-128-ECB.
|
||||
func downloadAndDecrypt(cdnBaseURL, encryptedQueryParam, aesKeyBase64 string) ([]byte, error) {
|
||||
key, err := parseAESKey(aesKeyBase64)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
u := buildCDNDownloadURL(encryptedQueryParam, cdnBaseURL)
|
||||
encrypted, err := fetchURL(u)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cdn download: %w", err)
|
||||
}
|
||||
return decryptAESECB(encrypted, key)
|
||||
}
|
||||
|
||||
// downloadPlain fetches unencrypted bytes from the CDN.
|
||||
func downloadPlain(cdnBaseURL, encryptedQueryParam string) ([]byte, error) {
|
||||
u := buildCDNDownloadURL(encryptedQueryParam, cdnBaseURL)
|
||||
return fetchURL(u)
|
||||
}
|
||||
|
||||
func fetchURL(u string) ([]byte, error) {
|
||||
resp, err := http.Get(u) //nolint:gosec,noctx
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("cdn %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
return io.ReadAll(resp.Body)
|
||||
}
|
||||
|
||||
// uploadToCDN encrypts and uploads bytes to the WeChat CDN, returning the download param.
|
||||
func uploadToCDN(cdnBaseURL, uploadParam, filekey string, plaintext, aesKey []byte) (string, error) {
|
||||
ciphertext, err := encryptAESECB(plaintext, aesKey)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cdn encrypt: %w", err)
|
||||
}
|
||||
u := buildCDNUploadURL(cdnBaseURL, uploadParam, filekey)
|
||||
|
||||
req, err := http.NewRequest(http.MethodPost, u, io.NopCloser(strings.NewReader(string(ciphertext)))) //nolint:noctx
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/octet-stream")
|
||||
resp, err := (&http.Client{Timeout: 30 * time.Second}).Do(req) //nolint:mnd,gosec // CDN URL from admin config
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cdn upload: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return "", fmt.Errorf("cdn upload %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
downloadParam := resp.Header.Get("x-encrypted-param")
|
||||
if downloadParam == "" {
|
||||
return "", errors.New("cdn upload: missing x-encrypted-param header")
|
||||
}
|
||||
return downloadParam, nil
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package weixin
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestEncryptDecryptAESECB(t *testing.T) {
|
||||
key := []byte("0123456789abcdef") // 16 bytes
|
||||
plaintext := []byte("hello world test")
|
||||
|
||||
ciphertext, err := encryptAESECB(plaintext, key)
|
||||
if err != nil {
|
||||
t.Fatalf("encrypt: %v", err)
|
||||
}
|
||||
if bytes.Equal(ciphertext, plaintext) {
|
||||
t.Error("ciphertext should differ from plaintext")
|
||||
}
|
||||
|
||||
decrypted, err := decryptAESECB(ciphertext, key)
|
||||
if err != nil {
|
||||
t.Fatalf("decrypt: %v", err)
|
||||
}
|
||||
if !bytes.Equal(decrypted, plaintext) {
|
||||
t.Errorf("decrypted = %q, want %q", string(decrypted), string(plaintext))
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncryptDecryptAESECB_ShortInput(t *testing.T) {
|
||||
key := []byte("0123456789abcdef")
|
||||
plaintext := []byte("hi")
|
||||
|
||||
ciphertext, err := encryptAESECB(plaintext, key)
|
||||
if err != nil {
|
||||
t.Fatalf("encrypt: %v", err)
|
||||
}
|
||||
decrypted, err := decryptAESECB(ciphertext, key)
|
||||
if err != nil {
|
||||
t.Fatalf("decrypt: %v", err)
|
||||
}
|
||||
if !bytes.Equal(decrypted, plaintext) {
|
||||
t.Errorf("decrypted = %q, want %q", string(decrypted), string(plaintext))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAESECBPaddedSize(t *testing.T) {
|
||||
tests := []struct {
|
||||
input int
|
||||
want int
|
||||
}{
|
||||
{0, 16},
|
||||
{1, 16},
|
||||
{15, 16},
|
||||
{16, 32},
|
||||
{17, 32},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
got := aesECBPaddedSize(tc.input)
|
||||
if got != tc.want {
|
||||
t.Errorf("aesECBPaddedSize(%d) = %d, want %d", tc.input, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAESKey_Raw16Bytes(t *testing.T) {
|
||||
raw := []byte("0123456789abcdef")
|
||||
b64 := base64.StdEncoding.EncodeToString(raw)
|
||||
key, err := parseAESKey(b64)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !bytes.Equal(key, raw) {
|
||||
t.Errorf("key = %x, want %x", key, raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAESKey_HexEncoded(t *testing.T) {
|
||||
rawKey := []byte("0123456789abcdef")
|
||||
hexStr := hex.EncodeToString(rawKey) // 32 hex chars
|
||||
b64 := base64.StdEncoding.EncodeToString([]byte(hexStr))
|
||||
key, err := parseAESKey(b64)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !bytes.Equal(key, rawKey) {
|
||||
t.Errorf("key = %x, want %x", key, rawKey)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAESKey_Invalid(t *testing.T) {
|
||||
b64 := base64.StdEncoding.EncodeToString([]byte("short"))
|
||||
_, err := parseAESKey(b64)
|
||||
if err == nil {
|
||||
t.Error("expected error for invalid key length")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
// Derived from @tencent-weixin/openclaw-weixin (MIT License, Copyright (c) 2026 Tencent Inc.)
|
||||
// See LICENSE in this directory for the full license text.
|
||||
|
||||
package weixin
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/memohai/memoh/internal/channel"
|
||||
)
|
||||
|
||||
// buildInboundMessage maps a WeixinMessage to a Memoh InboundMessage.
|
||||
func buildInboundMessage(msg WeixinMessage) (channel.InboundMessage, bool) {
|
||||
text, attachments := extractContent(msg)
|
||||
if strings.TrimSpace(text) == "" && len(attachments) == 0 {
|
||||
return channel.InboundMessage{}, false
|
||||
}
|
||||
|
||||
fromUserID := strings.TrimSpace(msg.FromUserID)
|
||||
if fromUserID == "" {
|
||||
return channel.InboundMessage{}, false
|
||||
}
|
||||
|
||||
msgID := strconv.FormatInt(msg.MessageID, 10)
|
||||
if msg.Seq > 0 {
|
||||
msgID = strconv.FormatInt(msg.MessageID, 10) + ":" + strconv.Itoa(msg.Seq)
|
||||
}
|
||||
|
||||
meta := map[string]any{
|
||||
"session_id": strings.TrimSpace(msg.SessionID),
|
||||
"seq": msg.Seq,
|
||||
}
|
||||
if msg.ContextToken != "" {
|
||||
meta["context_token"] = msg.ContextToken
|
||||
}
|
||||
|
||||
var receivedAt time.Time
|
||||
if msg.CreateTimeMs > 0 {
|
||||
receivedAt = time.UnixMilli(msg.CreateTimeMs)
|
||||
} else {
|
||||
receivedAt = time.Now().UTC()
|
||||
}
|
||||
|
||||
return channel.InboundMessage{
|
||||
Channel: Type,
|
||||
Message: channel.Message{
|
||||
ID: msgID,
|
||||
Format: channel.MessageFormatPlain,
|
||||
Text: text,
|
||||
Attachments: attachments,
|
||||
Metadata: meta,
|
||||
},
|
||||
ReplyTarget: fromUserID,
|
||||
Sender: channel.Identity{
|
||||
SubjectID: fromUserID,
|
||||
Attributes: map[string]string{
|
||||
"user_id": fromUserID,
|
||||
},
|
||||
},
|
||||
Conversation: channel.Conversation{
|
||||
ID: fromUserID,
|
||||
Type: channel.ConversationTypePrivate,
|
||||
},
|
||||
ReceivedAt: receivedAt,
|
||||
Source: "weixin",
|
||||
Metadata: meta,
|
||||
}, true
|
||||
}
|
||||
|
||||
// extractContent extracts text and attachments from the message item list.
|
||||
func extractContent(msg WeixinMessage) (string, []channel.Attachment) {
|
||||
if len(msg.ItemList) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
var textParts []string
|
||||
var attachments []channel.Attachment
|
||||
|
||||
for _, item := range msg.ItemList {
|
||||
switch item.Type {
|
||||
case ItemTypeText:
|
||||
t := extractTextFromItem(item)
|
||||
if t != "" {
|
||||
textParts = append(textParts, t)
|
||||
}
|
||||
case ItemTypeImage:
|
||||
if att, ok := buildImageAttachment(item); ok {
|
||||
attachments = append(attachments, att)
|
||||
}
|
||||
case ItemTypeVoice:
|
||||
if item.VoiceItem != nil && strings.TrimSpace(item.VoiceItem.Text) != "" && !hasMediaRef(item) {
|
||||
textParts = append(textParts, item.VoiceItem.Text)
|
||||
} else if att, ok := buildVoiceAttachment(item); ok {
|
||||
attachments = append(attachments, att)
|
||||
}
|
||||
case ItemTypeFile:
|
||||
if att, ok := buildFileAttachment(item); ok {
|
||||
attachments = append(attachments, att)
|
||||
}
|
||||
case ItemTypeVideo:
|
||||
if att, ok := buildVideoAttachment(item); ok {
|
||||
attachments = append(attachments, att)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return strings.Join(textParts, "\n"), attachments
|
||||
}
|
||||
|
||||
func extractTextFromItem(item MessageItem) string {
|
||||
if item.TextItem == nil || strings.TrimSpace(item.TextItem.Text) == "" {
|
||||
return ""
|
||||
}
|
||||
text := item.TextItem.Text
|
||||
ref := item.RefMsg
|
||||
if ref == nil {
|
||||
return text
|
||||
}
|
||||
if ref.MessageItem != nil && isMediaItemType(ref.MessageItem.Type) {
|
||||
return text
|
||||
}
|
||||
var parts []string
|
||||
if strings.TrimSpace(ref.Title) != "" {
|
||||
parts = append(parts, ref.Title)
|
||||
}
|
||||
if ref.MessageItem != nil {
|
||||
if ref.MessageItem.TextItem != nil && strings.TrimSpace(ref.MessageItem.TextItem.Text) != "" {
|
||||
parts = append(parts, ref.MessageItem.TextItem.Text)
|
||||
}
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
return text
|
||||
}
|
||||
return fmt.Sprintf("[引用: %s]\n%s", strings.Join(parts, " | "), text)
|
||||
}
|
||||
|
||||
func isMediaItemType(t int) bool {
|
||||
return t == ItemTypeImage || t == ItemTypeVideo || t == ItemTypeFile || t == ItemTypeVoice
|
||||
}
|
||||
|
||||
func hasMediaRef(item MessageItem) bool {
|
||||
return item.VoiceItem != nil && item.VoiceItem.Media != nil &&
|
||||
strings.TrimSpace(item.VoiceItem.Media.EncryptQueryParam) != ""
|
||||
}
|
||||
|
||||
func buildImageAttachment(item MessageItem) (channel.Attachment, bool) {
|
||||
img := item.ImageItem
|
||||
if img == nil || img.Media == nil || strings.TrimSpace(img.Media.EncryptQueryParam) == "" {
|
||||
return channel.Attachment{}, false
|
||||
}
|
||||
aesKey := resolveImageAESKey(img)
|
||||
return channel.Attachment{
|
||||
Type: channel.AttachmentImage,
|
||||
PlatformKey: img.Media.EncryptQueryParam,
|
||||
SourcePlatform: Type.String(),
|
||||
Metadata: map[string]any{
|
||||
"encrypt_query_param": img.Media.EncryptQueryParam,
|
||||
"aes_key": aesKey,
|
||||
},
|
||||
}, true
|
||||
}
|
||||
|
||||
// resolveImageAESKey picks the best AES key for image decryption.
|
||||
// Prefers the hex-encoded aeskey field, falling back to media.aes_key.
|
||||
func resolveImageAESKey(img *ImageItem) string {
|
||||
if strings.TrimSpace(img.AESKey) != "" {
|
||||
keyBytes, err := hex.DecodeString(img.AESKey)
|
||||
if err == nil {
|
||||
return base64.StdEncoding.EncodeToString(keyBytes)
|
||||
}
|
||||
}
|
||||
if img.Media != nil {
|
||||
return strings.TrimSpace(img.Media.AESKey)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func buildVoiceAttachment(item MessageItem) (channel.Attachment, bool) {
|
||||
v := item.VoiceItem
|
||||
if v == nil || v.Media == nil || strings.TrimSpace(v.Media.EncryptQueryParam) == "" || strings.TrimSpace(v.Media.AESKey) == "" {
|
||||
return channel.Attachment{}, false
|
||||
}
|
||||
return channel.Attachment{
|
||||
Type: channel.AttachmentVoice,
|
||||
PlatformKey: v.Media.EncryptQueryParam,
|
||||
SourcePlatform: Type.String(),
|
||||
DurationMs: int64(v.Playtime),
|
||||
Metadata: map[string]any{
|
||||
"encrypt_query_param": v.Media.EncryptQueryParam,
|
||||
"aes_key": v.Media.AESKey,
|
||||
"encode_type": v.EncodeType,
|
||||
},
|
||||
}, true
|
||||
}
|
||||
|
||||
func buildFileAttachment(item MessageItem) (channel.Attachment, bool) {
|
||||
f := item.FileItem
|
||||
if f == nil || f.Media == nil || strings.TrimSpace(f.Media.EncryptQueryParam) == "" || strings.TrimSpace(f.Media.AESKey) == "" {
|
||||
return channel.Attachment{}, false
|
||||
}
|
||||
return channel.Attachment{
|
||||
Type: channel.AttachmentFile,
|
||||
PlatformKey: f.Media.EncryptQueryParam,
|
||||
SourcePlatform: Type.String(),
|
||||
Name: strings.TrimSpace(f.FileName),
|
||||
Metadata: map[string]any{
|
||||
"encrypt_query_param": f.Media.EncryptQueryParam,
|
||||
"aes_key": f.Media.AESKey,
|
||||
},
|
||||
}, true
|
||||
}
|
||||
|
||||
func buildVideoAttachment(item MessageItem) (channel.Attachment, bool) {
|
||||
v := item.VideoItem
|
||||
if v == nil || v.Media == nil || strings.TrimSpace(v.Media.EncryptQueryParam) == "" || strings.TrimSpace(v.Media.AESKey) == "" {
|
||||
return channel.Attachment{}, false
|
||||
}
|
||||
return channel.Attachment{
|
||||
Type: channel.AttachmentVideo,
|
||||
PlatformKey: v.Media.EncryptQueryParam,
|
||||
SourcePlatform: Type.String(),
|
||||
Metadata: map[string]any{
|
||||
"encrypt_query_param": v.Media.EncryptQueryParam,
|
||||
"aes_key": v.Media.AESKey,
|
||||
},
|
||||
}, true
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
package weixin
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/memohai/memoh/internal/channel"
|
||||
)
|
||||
|
||||
func TestBuildInboundMessage_TextOnly(t *testing.T) {
|
||||
msg := WeixinMessage{
|
||||
MessageID: 12345,
|
||||
Seq: 1,
|
||||
FromUserID: "user1@im.wechat",
|
||||
CreateTimeMs: 1700000000000,
|
||||
ContextToken: "ctx-tok-1",
|
||||
ItemList: []MessageItem{
|
||||
{Type: ItemTypeText, TextItem: &TextItem{Text: "hello world"}},
|
||||
},
|
||||
}
|
||||
|
||||
inbound, ok := buildInboundMessage(msg)
|
||||
if !ok {
|
||||
t.Fatal("expected valid inbound message")
|
||||
}
|
||||
if inbound.Channel != Type {
|
||||
t.Errorf("channel = %v, want %v", inbound.Channel, Type)
|
||||
}
|
||||
if inbound.Message.Text != "hello world" {
|
||||
t.Errorf("text = %q, want %q", inbound.Message.Text, "hello world")
|
||||
}
|
||||
if inbound.ReplyTarget != "user1@im.wechat" {
|
||||
t.Errorf("reply_target = %q", inbound.ReplyTarget)
|
||||
}
|
||||
if inbound.Sender.SubjectID != "user1@im.wechat" {
|
||||
t.Errorf("sender = %q", inbound.Sender.SubjectID)
|
||||
}
|
||||
if inbound.Conversation.Type != channel.ConversationTypePrivate {
|
||||
t.Errorf("conv_type = %q", inbound.Conversation.Type)
|
||||
}
|
||||
if inbound.Message.ID != "12345:1" {
|
||||
t.Errorf("message_id = %q", inbound.Message.ID)
|
||||
}
|
||||
meta := inbound.Metadata
|
||||
if meta == nil {
|
||||
t.Fatal("metadata is nil")
|
||||
}
|
||||
if meta["context_token"] != "ctx-tok-1" {
|
||||
t.Errorf("context_token = %v", meta["context_token"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildInboundMessage_Empty(t *testing.T) {
|
||||
msg := WeixinMessage{
|
||||
MessageID: 1,
|
||||
FromUserID: "u1",
|
||||
ItemList: []MessageItem{},
|
||||
}
|
||||
_, ok := buildInboundMessage(msg)
|
||||
if ok {
|
||||
t.Error("expected false for empty message")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildInboundMessage_NoFrom(t *testing.T) {
|
||||
msg := WeixinMessage{
|
||||
MessageID: 1,
|
||||
ItemList: []MessageItem{{Type: ItemTypeText, TextItem: &TextItem{Text: "hi"}}},
|
||||
}
|
||||
_, ok := buildInboundMessage(msg)
|
||||
if ok {
|
||||
t.Error("expected false for message without from_user_id")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildInboundMessage_ImageAttachment(t *testing.T) {
|
||||
msg := WeixinMessage{
|
||||
MessageID: 1,
|
||||
FromUserID: "u1",
|
||||
ItemList: []MessageItem{
|
||||
{
|
||||
Type: ItemTypeImage,
|
||||
ImageItem: &ImageItem{
|
||||
Media: &CDNMedia{
|
||||
EncryptQueryParam: "enc-param-1",
|
||||
AESKey: "QUJDREVGR0hJSktMTU5PUA==", // base64 of 16 bytes
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
inbound, ok := buildInboundMessage(msg)
|
||||
if !ok {
|
||||
t.Fatal("expected valid inbound message")
|
||||
}
|
||||
if len(inbound.Message.Attachments) != 1 {
|
||||
t.Fatalf("attachments = %d, want 1", len(inbound.Message.Attachments))
|
||||
}
|
||||
att := inbound.Message.Attachments[0]
|
||||
if att.Type != channel.AttachmentImage {
|
||||
t.Errorf("attachment type = %v", att.Type)
|
||||
}
|
||||
if att.PlatformKey != "enc-param-1" {
|
||||
t.Errorf("platform_key = %q", att.PlatformKey)
|
||||
}
|
||||
if att.SourcePlatform != "weixin" {
|
||||
t.Errorf("source_platform = %q", att.SourcePlatform)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildInboundMessage_VoiceWithText(t *testing.T) {
|
||||
msg := WeixinMessage{
|
||||
MessageID: 1,
|
||||
FromUserID: "u1",
|
||||
ItemList: []MessageItem{
|
||||
{
|
||||
Type: ItemTypeVoice,
|
||||
VoiceItem: &VoiceItem{
|
||||
Text: "transcribed voice text",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
inbound, ok := buildInboundMessage(msg)
|
||||
if !ok {
|
||||
t.Fatal("expected valid inbound message")
|
||||
}
|
||||
if !strings.Contains(inbound.Message.Text, "transcribed voice text") {
|
||||
t.Errorf("text = %q, expected voice transcription", inbound.Message.Text)
|
||||
}
|
||||
if len(inbound.Message.Attachments) != 0 {
|
||||
t.Errorf("attachments = %d, want 0 (voice with text should be text only)", len(inbound.Message.Attachments))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildInboundMessage_QuotedText(t *testing.T) {
|
||||
msg := WeixinMessage{
|
||||
MessageID: 1,
|
||||
FromUserID: "u1",
|
||||
ItemList: []MessageItem{
|
||||
{
|
||||
Type: ItemTypeText,
|
||||
TextItem: &TextItem{Text: "my reply"},
|
||||
RefMsg: &RefMessage{
|
||||
Title: "Original",
|
||||
MessageItem: &MessageItem{
|
||||
Type: ItemTypeText,
|
||||
TextItem: &TextItem{Text: "original text"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
inbound, ok := buildInboundMessage(msg)
|
||||
if !ok {
|
||||
t.Fatal("expected valid inbound message")
|
||||
}
|
||||
if !strings.Contains(inbound.Message.Text, "引用") {
|
||||
t.Errorf("text should contain quoted context, got: %q", inbound.Message.Text)
|
||||
}
|
||||
if !strings.Contains(inbound.Message.Text, "my reply") {
|
||||
t.Errorf("text should contain reply text, got: %q", inbound.Message.Text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildInboundMessage_FileAttachment(t *testing.T) {
|
||||
msg := WeixinMessage{
|
||||
MessageID: 1,
|
||||
FromUserID: "u1",
|
||||
ItemList: []MessageItem{
|
||||
{
|
||||
Type: ItemTypeFile,
|
||||
FileItem: &FileItem{
|
||||
Media: &CDNMedia{
|
||||
EncryptQueryParam: "file-enc-1",
|
||||
AESKey: "QUJDREVGR0hJSktMTU5PUA==",
|
||||
},
|
||||
FileName: "report.pdf",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
inbound, ok := buildInboundMessage(msg)
|
||||
if !ok {
|
||||
t.Fatal("expected valid inbound message")
|
||||
}
|
||||
if len(inbound.Message.Attachments) != 1 {
|
||||
t.Fatalf("attachments = %d, want 1", len(inbound.Message.Attachments))
|
||||
}
|
||||
att := inbound.Message.Attachments[0]
|
||||
if att.Type != channel.AttachmentFile {
|
||||
t.Errorf("type = %v", att.Type)
|
||||
}
|
||||
if att.Name != "report.pdf" {
|
||||
t.Errorf("name = %q", att.Name)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
// Derived from @tencent-weixin/openclaw-weixin (MIT License, Copyright (c) 2026 Tencent Inc.)
|
||||
// See LICENSE in this directory for the full license text.
|
||||
|
||||
package weixin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/md5" //nolint:gosec
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/memohai/memoh/internal/media"
|
||||
)
|
||||
|
||||
type assetOpener interface {
|
||||
Open(ctx context.Context, botID, contentHash string) (io.ReadCloser, media.Asset, error)
|
||||
}
|
||||
|
||||
// sendText sends a plain text message through the WeChat API.
|
||||
func sendText(ctx context.Context, client *Client, cfg adapterConfig, target, text, contextToken string) error {
|
||||
if strings.TrimSpace(contextToken) == "" {
|
||||
return errors.New("weixin: context_token is required to send messages")
|
||||
}
|
||||
clientID := generateClientID()
|
||||
req := SendMessageRequest{
|
||||
Msg: WeixinMessage{
|
||||
ToUserID: target,
|
||||
ClientID: clientID,
|
||||
MessageType: MessageTypeBot,
|
||||
MessageState: MessageStateFinish,
|
||||
ItemList: []MessageItem{
|
||||
{Type: ItemTypeText, TextItem: &TextItem{Text: text}},
|
||||
},
|
||||
ContextToken: contextToken,
|
||||
},
|
||||
}
|
||||
return client.SendMessage(ctx, cfg, req)
|
||||
}
|
||||
|
||||
// sendImageFromReader uploads an image and sends it.
|
||||
func sendImageFromReader(ctx context.Context, client *Client, cfg adapterConfig, target, contextToken, text string, r io.Reader, logger *slog.Logger) error {
|
||||
data, err := io.ReadAll(r)
|
||||
if err != nil {
|
||||
return fmt.Errorf("weixin: read image: %w", err)
|
||||
}
|
||||
return sendMediaBytes(ctx, client, cfg, target, contextToken, text, data, UploadMediaImage, ItemTypeImage, logger)
|
||||
}
|
||||
|
||||
// sendFileFromReader uploads a file and sends it.
|
||||
func sendFileFromReader(ctx context.Context, client *Client, cfg adapterConfig, target, contextToken, text, fileName string, r io.Reader, logger *slog.Logger) error {
|
||||
data, err := io.ReadAll(r)
|
||||
if err != nil {
|
||||
return fmt.Errorf("weixin: read file: %w", err)
|
||||
}
|
||||
return sendMediaBytesAsFile(ctx, client, cfg, target, contextToken, text, fileName, data, logger)
|
||||
}
|
||||
|
||||
func sendMediaBytes(ctx context.Context, client *Client, cfg adapterConfig, target, contextToken, text string, data []byte, uploadType, itemType int, logger *slog.Logger) error {
|
||||
if strings.TrimSpace(contextToken) == "" {
|
||||
return errors.New("weixin: context_token is required for media send")
|
||||
}
|
||||
|
||||
aesKey := make([]byte, 16)
|
||||
if _, err := rand.Read(aesKey); err != nil {
|
||||
return fmt.Errorf("weixin: gen aes key: %w", err)
|
||||
}
|
||||
filekey := make([]byte, 16)
|
||||
if _, err := rand.Read(filekey); err != nil {
|
||||
return fmt.Errorf("weixin: gen filekey: %w", err)
|
||||
}
|
||||
filekeyHex := hex.EncodeToString(filekey)
|
||||
rawMD5 := md5.Sum(data) //nolint:gosec
|
||||
rawMD5Hex := hex.EncodeToString(rawMD5[:])
|
||||
fileSize := aesECBPaddedSize(len(data))
|
||||
|
||||
uploadResp, err := client.GetUploadURL(ctx, cfg, GetUploadURLRequest{
|
||||
FileKey: filekeyHex,
|
||||
MediaType: uploadType,
|
||||
ToUserID: target,
|
||||
RawSize: len(data),
|
||||
RawFileMD5: rawMD5Hex,
|
||||
FileSize: fileSize,
|
||||
NoNeedThumb: true,
|
||||
AESKey: hex.EncodeToString(aesKey),
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("weixin: get upload url: %w", err)
|
||||
}
|
||||
if strings.TrimSpace(uploadResp.UploadParam) == "" {
|
||||
return errors.New("weixin: empty upload_param")
|
||||
}
|
||||
|
||||
downloadParam, err := uploadToCDN(cfg.CDNBaseURL, uploadResp.UploadParam, filekeyHex, data, aesKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("weixin: cdn upload: %w", err)
|
||||
}
|
||||
|
||||
var mediaItem MessageItem
|
||||
switch itemType {
|
||||
case ItemTypeImage:
|
||||
mediaItem = MessageItem{
|
||||
Type: ItemTypeImage,
|
||||
ImageItem: &ImageItem{
|
||||
Media: &CDNMedia{
|
||||
EncryptQueryParam: downloadParam,
|
||||
AESKey: encodeAESKeyForSend(aesKey),
|
||||
EncryptType: 1,
|
||||
},
|
||||
MidSize: fileSize,
|
||||
},
|
||||
}
|
||||
case ItemTypeVideo:
|
||||
mediaItem = MessageItem{
|
||||
Type: ItemTypeVideo,
|
||||
VideoItem: &VideoItem{
|
||||
Media: &CDNMedia{
|
||||
EncryptQueryParam: downloadParam,
|
||||
AESKey: encodeAESKeyForSend(aesKey),
|
||||
EncryptType: 1,
|
||||
},
|
||||
VideoSize: fileSize,
|
||||
},
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("weixin: unsupported media item type %d", itemType)
|
||||
}
|
||||
|
||||
if logger != nil {
|
||||
logger.Debug("weixin media uploaded",
|
||||
slog.String("filekey", filekeyHex),
|
||||
slog.Int("raw_size", len(data)),
|
||||
slog.Int("cipher_size", fileSize),
|
||||
)
|
||||
}
|
||||
|
||||
items := make([]MessageItem, 0, 2)
|
||||
if strings.TrimSpace(text) != "" {
|
||||
items = append(items, MessageItem{Type: ItemTypeText, TextItem: &TextItem{Text: text}})
|
||||
}
|
||||
items = append(items, mediaItem)
|
||||
|
||||
for _, it := range items {
|
||||
req := SendMessageRequest{
|
||||
Msg: WeixinMessage{
|
||||
ToUserID: target,
|
||||
ClientID: generateClientID(),
|
||||
MessageType: MessageTypeBot,
|
||||
MessageState: MessageStateFinish,
|
||||
ItemList: []MessageItem{it},
|
||||
ContextToken: contextToken,
|
||||
},
|
||||
}
|
||||
if err := client.SendMessage(ctx, cfg, req); err != nil {
|
||||
return fmt.Errorf("weixin: send media item: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func sendMediaBytesAsFile(ctx context.Context, client *Client, cfg adapterConfig, target, contextToken, text, fileName string, data []byte, logger *slog.Logger) error {
|
||||
if strings.TrimSpace(contextToken) == "" {
|
||||
return errors.New("weixin: context_token is required for file send")
|
||||
}
|
||||
|
||||
aesKey := make([]byte, 16)
|
||||
if _, err := rand.Read(aesKey); err != nil {
|
||||
return fmt.Errorf("weixin: gen aes key: %w", err)
|
||||
}
|
||||
filekey := make([]byte, 16)
|
||||
if _, err := rand.Read(filekey); err != nil {
|
||||
return fmt.Errorf("weixin: gen filekey: %w", err)
|
||||
}
|
||||
filekeyHex := hex.EncodeToString(filekey)
|
||||
rawMD5 := md5.Sum(data) //nolint:gosec
|
||||
rawMD5Hex := hex.EncodeToString(rawMD5[:])
|
||||
fileSize := aesECBPaddedSize(len(data))
|
||||
|
||||
uploadResp, err := client.GetUploadURL(ctx, cfg, GetUploadURLRequest{
|
||||
FileKey: filekeyHex,
|
||||
MediaType: UploadMediaFile,
|
||||
ToUserID: target,
|
||||
RawSize: len(data),
|
||||
RawFileMD5: rawMD5Hex,
|
||||
FileSize: fileSize,
|
||||
NoNeedThumb: true,
|
||||
AESKey: hex.EncodeToString(aesKey),
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("weixin: get upload url: %w", err)
|
||||
}
|
||||
if strings.TrimSpace(uploadResp.UploadParam) == "" {
|
||||
return errors.New("weixin: empty upload_param")
|
||||
}
|
||||
|
||||
downloadParam, err := uploadToCDN(cfg.CDNBaseURL, uploadResp.UploadParam, filekeyHex, data, aesKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("weixin: cdn upload: %w", err)
|
||||
}
|
||||
|
||||
if logger != nil {
|
||||
logger.Debug("weixin file uploaded",
|
||||
slog.String("filekey", filekeyHex),
|
||||
slog.String("filename", fileName),
|
||||
slog.Int("raw_size", len(data)),
|
||||
)
|
||||
}
|
||||
|
||||
fileItem := MessageItem{
|
||||
Type: ItemTypeFile,
|
||||
FileItem: &FileItem{
|
||||
Media: &CDNMedia{
|
||||
EncryptQueryParam: downloadParam,
|
||||
AESKey: encodeAESKeyForSend(aesKey),
|
||||
EncryptType: 1,
|
||||
},
|
||||
FileName: fileName,
|
||||
Len: strconv.Itoa(len(data)),
|
||||
},
|
||||
}
|
||||
|
||||
items := make([]MessageItem, 0, 2)
|
||||
if strings.TrimSpace(text) != "" {
|
||||
items = append(items, MessageItem{Type: ItemTypeText, TextItem: &TextItem{Text: text}})
|
||||
}
|
||||
items = append(items, fileItem)
|
||||
|
||||
for _, it := range items {
|
||||
req := SendMessageRequest{
|
||||
Msg: WeixinMessage{
|
||||
ToUserID: target,
|
||||
ClientID: generateClientID(),
|
||||
MessageType: MessageTypeBot,
|
||||
MessageState: MessageStateFinish,
|
||||
ItemList: []MessageItem{it},
|
||||
ContextToken: contextToken,
|
||||
},
|
||||
}
|
||||
if err := client.SendMessage(ctx, cfg, req); err != nil {
|
||||
return fmt.Errorf("weixin: send file item: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// encodeAESKeyForSend encodes a raw 16-byte AES key for the sendmessage protocol.
|
||||
func encodeAESKeyForSend(key []byte) string {
|
||||
hexStr := hex.EncodeToString(key)
|
||||
return strings.TrimSpace(hexStr)
|
||||
}
|
||||
|
||||
func generateClientID() string {
|
||||
b := make([]byte, 8)
|
||||
_, _ = rand.Read(b)
|
||||
return "memoh-weixin-" + hex.EncodeToString(b)
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
package weixin
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
|
||||
"github.com/memohai/memoh/internal/channel"
|
||||
)
|
||||
|
||||
// QRHandler handles WeChat QR code login for the management UI.
|
||||
type QRHandler struct {
|
||||
logger *slog.Logger
|
||||
client *Client
|
||||
lifecycle *channel.Lifecycle
|
||||
}
|
||||
|
||||
// NewQRHandler creates a QR handler.
|
||||
func NewQRHandler(log *slog.Logger, lifecycle *channel.Lifecycle) *QRHandler {
|
||||
if log == nil {
|
||||
log = slog.Default()
|
||||
}
|
||||
return &QRHandler{
|
||||
logger: log.With(slog.String("handler", "weixin_qr")),
|
||||
client: NewClient(log),
|
||||
lifecycle: lifecycle,
|
||||
}
|
||||
}
|
||||
|
||||
// NewQRServerHandler is a DI-friendly constructor for fx, returning the handler
|
||||
// that implements server.Handler.
|
||||
func NewQRServerHandler(log *slog.Logger, lifecycle *channel.Lifecycle) *QRHandler {
|
||||
return NewQRHandler(log, lifecycle)
|
||||
}
|
||||
|
||||
// Register registers QR login routes on the Echo instance.
|
||||
func (h *QRHandler) Register(e *echo.Echo) {
|
||||
e.POST("/bots/:id/channel/weixin/qr/start", h.Start)
|
||||
e.POST("/bots/:id/channel/weixin/qr/poll", h.Poll)
|
||||
}
|
||||
|
||||
// QRStartResponse returns QR code data to the frontend.
|
||||
type QRStartResponse struct {
|
||||
QRCodeURL string `json:"qr_code_url"`
|
||||
QRCode string `json:"qr_code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// Start godoc
|
||||
// @Summary Start WeChat QR login
|
||||
// @Description Fetch a QR code from WeChat for scanning.
|
||||
// @Tags bots
|
||||
// @Param id path string true "Bot ID"
|
||||
// @Success 200 {object} QRStartResponse
|
||||
// @Failure 500 {object} map[string]string
|
||||
// @Router /bots/{id}/channel/weixin/qr/start [post].
|
||||
func (h *QRHandler) Start(c echo.Context) error {
|
||||
qr, err := h.client.FetchQRCode(c.Request().Context(), defaultBaseURL)
|
||||
if err != nil {
|
||||
h.logger.Error("weixin qr start failed", slog.Any("error", err))
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to fetch QR code: "+err.Error())
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, QRStartResponse{
|
||||
QRCodeURL: strings.TrimSpace(qr.QRCodeImgContent),
|
||||
QRCode: strings.TrimSpace(qr.QRCode),
|
||||
Message: "Scan the QR code with WeChat",
|
||||
})
|
||||
}
|
||||
|
||||
// QRPollRequest is the request body for polling QR status.
|
||||
type QRPollRequest struct {
|
||||
QRCode string `json:"qr_code"`
|
||||
}
|
||||
|
||||
// QRPollResponse returns the poll result.
|
||||
type QRPollResponse struct {
|
||||
Status string `json:"status"` // wait, scaned, confirmed, expired
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// Poll godoc
|
||||
// @Summary Poll WeChat QR login status
|
||||
// @Description Long-poll the QR code scan status. On confirmed, auto-saves credentials.
|
||||
// @Tags bots
|
||||
// @Param id path string true "Bot ID"
|
||||
// @Param payload body QRPollRequest true "QR code to poll"
|
||||
// @Success 200 {object} QRPollResponse
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 500 {object} map[string]string
|
||||
// @Router /bots/{id}/channel/weixin/qr/poll [post].
|
||||
func (h *QRHandler) Poll(c echo.Context) error {
|
||||
botID := strings.TrimSpace(c.Param("id"))
|
||||
if botID == "" {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "bot id is required")
|
||||
}
|
||||
|
||||
var req QRPollRequest
|
||||
if err := c.Bind(&req); err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
|
||||
}
|
||||
qrCode := strings.TrimSpace(req.QRCode)
|
||||
if qrCode == "" {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "qr_code is required")
|
||||
}
|
||||
|
||||
status, err := h.client.PollQRStatus(c.Request().Context(), defaultBaseURL, qrCode)
|
||||
if err != nil {
|
||||
h.logger.Error("weixin qr poll failed", slog.Any("error", err))
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Poll failed: "+err.Error())
|
||||
}
|
||||
|
||||
resp := QRPollResponse{
|
||||
Status: status.Status,
|
||||
Message: statusMessage(status.Status),
|
||||
}
|
||||
|
||||
if status.Status == "confirmed" && strings.TrimSpace(status.BotToken) != "" {
|
||||
resolvedBaseURL := defaultBaseURL
|
||||
if strings.TrimSpace(status.BaseURL) != "" {
|
||||
resolvedBaseURL = strings.TrimSpace(status.BaseURL)
|
||||
}
|
||||
|
||||
if h.lifecycle != nil {
|
||||
credentials := map[string]any{
|
||||
"token": status.BotToken,
|
||||
"baseUrl": resolvedBaseURL,
|
||||
}
|
||||
|
||||
_, saveErr := h.lifecycle.UpsertBotChannelConfig(
|
||||
c.Request().Context(),
|
||||
botID,
|
||||
Type,
|
||||
channel.UpsertConfigRequest{
|
||||
Credentials: credentials,
|
||||
Disabled: boolPtr(false),
|
||||
},
|
||||
)
|
||||
if saveErr != nil {
|
||||
h.logger.Error("weixin qr save credentials failed",
|
||||
slog.String("bot_id", botID),
|
||||
slog.Any("error", saveErr),
|
||||
)
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Login succeeded but failed to save credentials: "+saveErr.Error())
|
||||
}
|
||||
h.logger.Info("weixin qr login saved",
|
||||
slog.String("bot_id", botID),
|
||||
slog.String("account_id", status.ILinkBotID),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
func statusMessage(s string) string {
|
||||
switch s {
|
||||
case "wait":
|
||||
return "Waiting for scan..."
|
||||
case "scaned":
|
||||
return "Scanned — confirm on your phone"
|
||||
case "confirmed":
|
||||
return "Login successful"
|
||||
case "expired":
|
||||
return "QR code expired"
|
||||
default:
|
||||
return s
|
||||
}
|
||||
}
|
||||
|
||||
func boolPtr(b bool) *bool { return &b }
|
||||
@@ -0,0 +1,218 @@
|
||||
// Derived from @tencent-weixin/openclaw-weixin (MIT License, Copyright (c) 2026 Tencent Inc.)
|
||||
// See LICENSE in this directory for the full license text.
|
||||
|
||||
package weixin
|
||||
|
||||
// WeChat iLink protocol types.
|
||||
// Mirrors the JSON structures used by the getupdates / sendmessage / getuploadurl / getconfig / sendtyping APIs.
|
||||
|
||||
// BaseInfo is common metadata attached to every outgoing API request.
|
||||
type BaseInfo struct {
|
||||
ChannelVersion string `json:"channel_version,omitempty"`
|
||||
}
|
||||
|
||||
// MessageItemType constants for message items.
|
||||
const (
|
||||
ItemTypeNone = 0
|
||||
ItemTypeText = 1
|
||||
ItemTypeImage = 2
|
||||
ItemTypeVoice = 3
|
||||
ItemTypeFile = 4
|
||||
ItemTypeVideo = 5
|
||||
)
|
||||
|
||||
// MessageType sender type.
|
||||
const (
|
||||
MessageTypeNone = 0
|
||||
MessageTypeUser = 1
|
||||
MessageTypeBot = 2
|
||||
)
|
||||
|
||||
// MessageState lifecycle.
|
||||
const (
|
||||
MessageStateNew = 0
|
||||
MessageStateGenerating = 1
|
||||
MessageStateFinish = 2
|
||||
)
|
||||
|
||||
// UploadMediaType for getUploadUrl.
|
||||
const (
|
||||
UploadMediaImage = 1
|
||||
UploadMediaVideo = 2
|
||||
UploadMediaFile = 3
|
||||
UploadMediaVoice = 4
|
||||
)
|
||||
|
||||
// TypingStatus values.
|
||||
const (
|
||||
TypingStatusTyping = 1
|
||||
TypingStatusCancel = 2
|
||||
)
|
||||
|
||||
// CDNMedia is a CDN reference attached to images/voices/files/videos.
|
||||
type CDNMedia struct {
|
||||
EncryptQueryParam string `json:"encrypt_query_param,omitempty"`
|
||||
AESKey string `json:"aes_key,omitempty"`
|
||||
EncryptType int `json:"encrypt_type,omitempty"`
|
||||
}
|
||||
|
||||
type TextItem struct {
|
||||
Text string `json:"text,omitempty"`
|
||||
}
|
||||
|
||||
type ImageItem struct {
|
||||
Media *CDNMedia `json:"media,omitempty"`
|
||||
ThumbMedia *CDNMedia `json:"thumb_media,omitempty"`
|
||||
AESKey string `json:"aeskey,omitempty"` // hex-encoded preferred key
|
||||
URL string `json:"url,omitempty"`
|
||||
MidSize int `json:"mid_size,omitempty"`
|
||||
ThumbSize int `json:"thumb_size,omitempty"`
|
||||
ThumbHeight int `json:"thumb_height,omitempty"`
|
||||
ThumbWidth int `json:"thumb_width,omitempty"`
|
||||
HDSize int `json:"hd_size,omitempty"`
|
||||
}
|
||||
|
||||
type VoiceItem struct {
|
||||
Media *CDNMedia `json:"media,omitempty"`
|
||||
EncodeType int `json:"encode_type,omitempty"`
|
||||
BitsPerSample int `json:"bits_per_sample,omitempty"`
|
||||
SampleRate int `json:"sample_rate,omitempty"`
|
||||
Playtime int `json:"playtime,omitempty"` // ms
|
||||
Text string `json:"text,omitempty"` // speech-to-text
|
||||
}
|
||||
|
||||
type FileItem struct {
|
||||
Media *CDNMedia `json:"media,omitempty"`
|
||||
FileName string `json:"file_name,omitempty"`
|
||||
MD5 string `json:"md5,omitempty"`
|
||||
Len string `json:"len,omitempty"`
|
||||
}
|
||||
|
||||
type VideoItem struct {
|
||||
Media *CDNMedia `json:"media,omitempty"`
|
||||
VideoSize int `json:"video_size,omitempty"`
|
||||
PlayLength int `json:"play_length,omitempty"`
|
||||
VideoMD5 string `json:"video_md5,omitempty"`
|
||||
ThumbMedia *CDNMedia `json:"thumb_media,omitempty"`
|
||||
ThumbSize int `json:"thumb_size,omitempty"`
|
||||
ThumbHeight int `json:"thumb_height,omitempty"`
|
||||
ThumbWidth int `json:"thumb_width,omitempty"`
|
||||
}
|
||||
|
||||
type RefMessage struct {
|
||||
MessageItem *MessageItem `json:"message_item,omitempty"`
|
||||
Title string `json:"title,omitempty"`
|
||||
}
|
||||
|
||||
type MessageItem struct {
|
||||
Type int `json:"type,omitempty"`
|
||||
CreateTimeMs int64 `json:"create_time_ms,omitempty"`
|
||||
UpdateTimeMs int64 `json:"update_time_ms,omitempty"`
|
||||
IsCompleted bool `json:"is_completed,omitempty"`
|
||||
MsgID string `json:"msg_id,omitempty"`
|
||||
RefMsg *RefMessage `json:"ref_msg,omitempty"`
|
||||
TextItem *TextItem `json:"text_item,omitempty"`
|
||||
ImageItem *ImageItem `json:"image_item,omitempty"`
|
||||
VoiceItem *VoiceItem `json:"voice_item,omitempty"`
|
||||
FileItem *FileItem `json:"file_item,omitempty"`
|
||||
VideoItem *VideoItem `json:"video_item,omitempty"`
|
||||
}
|
||||
|
||||
// WeixinMessage is a unified message from the getupdates response.
|
||||
type WeixinMessage struct {
|
||||
Seq int `json:"seq,omitempty"`
|
||||
MessageID int64 `json:"message_id,omitempty"`
|
||||
FromUserID string `json:"from_user_id,omitempty"`
|
||||
ToUserID string `json:"to_user_id,omitempty"`
|
||||
ClientID string `json:"client_id,omitempty"`
|
||||
CreateTimeMs int64 `json:"create_time_ms,omitempty"`
|
||||
UpdateTimeMs int64 `json:"update_time_ms,omitempty"`
|
||||
DeleteTimeMs int64 `json:"delete_time_ms,omitempty"`
|
||||
SessionID string `json:"session_id,omitempty"`
|
||||
GroupID string `json:"group_id,omitempty"`
|
||||
MessageType int `json:"message_type,omitempty"`
|
||||
MessageState int `json:"message_state,omitempty"`
|
||||
ItemList []MessageItem `json:"item_list,omitempty"`
|
||||
ContextToken string `json:"context_token,omitempty"`
|
||||
}
|
||||
|
||||
// GetUpdatesRequest is the getupdates request body.
|
||||
type GetUpdatesRequest struct {
|
||||
GetUpdatesBuf string `json:"get_updates_buf"`
|
||||
BaseInfo BaseInfo `json:"base_info,omitempty"`
|
||||
}
|
||||
|
||||
// GetUpdatesResponse is the getupdates response body.
|
||||
type GetUpdatesResponse struct {
|
||||
Ret int `json:"ret"`
|
||||
ErrCode int `json:"errcode,omitempty"`
|
||||
ErrMsg string `json:"errmsg,omitempty"`
|
||||
Msgs []WeixinMessage `json:"msgs,omitempty"`
|
||||
GetUpdatesBuf string `json:"get_updates_buf,omitempty"`
|
||||
LongPollingTimeout int `json:"longpolling_timeout_ms,omitempty"`
|
||||
}
|
||||
|
||||
// SendMessageRequest wraps a single message for the sendmessage API.
|
||||
type SendMessageRequest struct {
|
||||
Msg WeixinMessage `json:"msg"`
|
||||
BaseInfo BaseInfo `json:"base_info,omitempty"`
|
||||
}
|
||||
|
||||
// GetUploadURLRequest is the getuploadurl request body.
|
||||
type GetUploadURLRequest struct {
|
||||
FileKey string `json:"filekey,omitempty"`
|
||||
MediaType int `json:"media_type,omitempty"`
|
||||
ToUserID string `json:"to_user_id,omitempty"`
|
||||
RawSize int `json:"rawsize,omitempty"`
|
||||
RawFileMD5 string `json:"rawfilemd5,omitempty"`
|
||||
FileSize int `json:"filesize,omitempty"`
|
||||
ThumbRawSize int `json:"thumb_rawsize,omitempty"`
|
||||
ThumbRawMD5 string `json:"thumb_rawfilemd5,omitempty"`
|
||||
ThumbFileSize int `json:"thumb_filesize,omitempty"`
|
||||
NoNeedThumb bool `json:"no_need_thumb,omitempty"`
|
||||
AESKey string `json:"aeskey,omitempty"`
|
||||
BaseInfo BaseInfo `json:"base_info,omitempty"`
|
||||
}
|
||||
|
||||
// GetUploadURLResponse contains CDN upload params.
|
||||
type GetUploadURLResponse struct {
|
||||
UploadParam string `json:"upload_param,omitempty"`
|
||||
ThumbUploadParam string `json:"thumb_upload_param,omitempty"`
|
||||
}
|
||||
|
||||
// GetConfigRequest is the getconfig request body.
|
||||
type GetConfigRequest struct {
|
||||
ILinkUserID string `json:"ilink_user_id,omitempty"`
|
||||
ContextToken string `json:"context_token,omitempty"`
|
||||
BaseInfo BaseInfo `json:"base_info,omitempty"`
|
||||
}
|
||||
|
||||
// GetConfigResponse contains bot config (typing ticket etc.).
|
||||
type GetConfigResponse struct {
|
||||
Ret int `json:"ret"`
|
||||
ErrMsg string `json:"errmsg,omitempty"`
|
||||
TypingTicket string `json:"typing_ticket,omitempty"`
|
||||
}
|
||||
|
||||
// SendTypingRequest is the sendtyping request body.
|
||||
type SendTypingRequest struct {
|
||||
ILinkUserID string `json:"ilink_user_id,omitempty"`
|
||||
TypingTicket string `json:"typing_ticket,omitempty"`
|
||||
Status int `json:"status,omitempty"`
|
||||
BaseInfo BaseInfo `json:"base_info,omitempty"`
|
||||
}
|
||||
|
||||
// QRCodeResponse from get_bot_qrcode.
|
||||
type QRCodeResponse struct {
|
||||
QRCode string `json:"qrcode"`
|
||||
QRCodeImgContent string `json:"qrcode_img_content"`
|
||||
}
|
||||
|
||||
// QRStatusResponse from get_qrcode_status.
|
||||
type QRStatusResponse struct {
|
||||
Status string `json:"status"` // wait, scaned, confirmed, expired
|
||||
BotToken string `json:"bot_token,omitempty"`
|
||||
ILinkBotID string `json:"ilink_bot_id,omitempty"`
|
||||
BaseURL string `json:"baseurl,omitempty"`
|
||||
ILinkUserID string `json:"ilink_user_id,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,526 @@
|
||||
package weixin
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
attachmentpkg "github.com/memohai/memoh/internal/attachment"
|
||||
"github.com/memohai/memoh/internal/channel"
|
||||
)
|
||||
|
||||
// Type is the channel type identifier for WeChat.
|
||||
const Type channel.ChannelType = "weixin"
|
||||
|
||||
// WeixinAdapter is the Memoh channel adapter for personal WeChat via the Tencent iLink API.
|
||||
type WeixinAdapter struct {
|
||||
logger *slog.Logger
|
||||
client *Client
|
||||
contextCache *contextTokenCache
|
||||
assets assetOpener
|
||||
}
|
||||
|
||||
// NewWeixinAdapter creates a new WeChat adapter.
|
||||
func NewWeixinAdapter(log *slog.Logger) *WeixinAdapter {
|
||||
if log == nil {
|
||||
log = slog.Default()
|
||||
}
|
||||
return &WeixinAdapter{
|
||||
logger: log.With(slog.String("adapter", "weixin")),
|
||||
client: NewClient(log),
|
||||
contextCache: newContextTokenCache(24 * time.Hour),
|
||||
}
|
||||
}
|
||||
|
||||
// SetAssetOpener configures the media asset reader for outbound attachments.
|
||||
func (a *WeixinAdapter) SetAssetOpener(opener assetOpener) {
|
||||
a.assets = opener
|
||||
}
|
||||
|
||||
func (*WeixinAdapter) Type() channel.ChannelType { return Type }
|
||||
|
||||
func (*WeixinAdapter) Descriptor() channel.Descriptor {
|
||||
return channel.Descriptor{
|
||||
Type: Type,
|
||||
DisplayName: "WeChat",
|
||||
Capabilities: channel.ChannelCapabilities{
|
||||
Text: true,
|
||||
Attachments: true,
|
||||
Media: true,
|
||||
Reply: true,
|
||||
BlockStreaming: true,
|
||||
ChatTypes: []string{channel.ConversationTypePrivate},
|
||||
},
|
||||
OutboundPolicy: channel.OutboundPolicy{
|
||||
TextChunkLimit: 4000,
|
||||
},
|
||||
ConfigSchema: channel.ConfigSchema{
|
||||
Version: 1,
|
||||
Fields: map[string]channel.FieldSchema{
|
||||
"token": {Type: channel.FieldSecret, Required: true, Title: "Token"},
|
||||
"pollTimeoutSeconds": {Type: channel.FieldNumber, Title: "Poll Timeout (s)"},
|
||||
"enableTyping": {Type: channel.FieldBool, Title: "Enable Typing Indicator"},
|
||||
},
|
||||
},
|
||||
UserConfigSchema: channel.ConfigSchema{
|
||||
Version: 1,
|
||||
Fields: map[string]channel.FieldSchema{
|
||||
"user_id": {Type: channel.FieldString, Required: true, Title: "WeChat User ID"},
|
||||
},
|
||||
},
|
||||
TargetSpec: channel.TargetSpec{
|
||||
Format: "<user_id>",
|
||||
Hints: []channel.TargetHint{
|
||||
{Label: "User ID", Example: "abc123@im.wechat"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// -- ConfigNormalizer --
|
||||
|
||||
func (*WeixinAdapter) NormalizeConfig(raw map[string]any) (map[string]any, error) {
|
||||
return normalizeConfig(raw)
|
||||
}
|
||||
|
||||
func (*WeixinAdapter) NormalizeUserConfig(raw map[string]any) (map[string]any, error) {
|
||||
return normalizeUserConfig(raw)
|
||||
}
|
||||
|
||||
// -- TargetResolver --
|
||||
|
||||
func (*WeixinAdapter) NormalizeTarget(raw string) string { return normalizeTarget(raw) }
|
||||
|
||||
func (*WeixinAdapter) ResolveTarget(userConfig map[string]any) (string, error) {
|
||||
return resolveTarget(userConfig)
|
||||
}
|
||||
|
||||
// -- BindingMatcher --
|
||||
|
||||
func (*WeixinAdapter) MatchBinding(config map[string]any, criteria channel.BindingCriteria) bool {
|
||||
return matchBinding(config, criteria)
|
||||
}
|
||||
|
||||
func (*WeixinAdapter) BuildUserConfig(identity channel.Identity) map[string]any {
|
||||
return buildUserConfig(identity)
|
||||
}
|
||||
|
||||
// -- Receiver (long-poll) --
|
||||
|
||||
func (a *WeixinAdapter) Connect(ctx context.Context, cfg channel.ChannelConfig, handler channel.InboundHandler) (channel.Connection, error) {
|
||||
parsed, err := parseConfig(cfg.Credentials)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
connCtx, cancel := context.WithCancel(ctx)
|
||||
done := make(chan struct{})
|
||||
|
||||
go func() {
|
||||
defer close(done)
|
||||
a.pollLoop(connCtx, cfg, parsed, handler)
|
||||
}()
|
||||
|
||||
stop := func(context.Context) error {
|
||||
cancel()
|
||||
<-done
|
||||
return nil
|
||||
}
|
||||
return channel.NewConnection(cfg, stop), nil
|
||||
}
|
||||
|
||||
func (a *WeixinAdapter) pollLoop(ctx context.Context, cfg channel.ChannelConfig, parsed adapterConfig, handler channel.InboundHandler) {
|
||||
const (
|
||||
maxConsecutiveFailures = 3
|
||||
backoffDelay = 30 * time.Second
|
||||
retryDelay = 2 * time.Second
|
||||
sessionPauseDuration = 1 * time.Hour
|
||||
)
|
||||
|
||||
var getUpdatesBuf string
|
||||
var consecutiveFailures int
|
||||
|
||||
a.logger.Info("weixin poll loop started",
|
||||
slog.String("config_id", cfg.ID),
|
||||
slog.String("bot_id", cfg.BotID),
|
||||
slog.String("base_url", parsed.BaseURL),
|
||||
)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
a.logger.Info("weixin poll loop stopped", slog.String("config_id", cfg.ID))
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
resp, err := a.client.GetUpdates(ctx, parsed, getUpdatesBuf)
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
consecutiveFailures++
|
||||
a.logger.Error("weixin getupdates error",
|
||||
slog.String("config_id", cfg.ID),
|
||||
slog.Any("error", err),
|
||||
slog.Int("failures", consecutiveFailures),
|
||||
)
|
||||
if consecutiveFailures >= maxConsecutiveFailures {
|
||||
consecutiveFailures = 0
|
||||
sleepCtx(ctx, backoffDelay)
|
||||
} else {
|
||||
sleepCtx(ctx, retryDelay)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Handle API-level errors.
|
||||
isAPIError := (resp.Ret != 0) || (resp.ErrCode != 0)
|
||||
if isAPIError {
|
||||
if resp.ErrCode == sessionExpiredErrCode || resp.Ret == sessionExpiredErrCode {
|
||||
a.logger.Error("weixin session expired, pausing",
|
||||
slog.String("config_id", cfg.ID),
|
||||
slog.Int("errcode", resp.ErrCode),
|
||||
)
|
||||
sleepCtx(ctx, sessionPauseDuration)
|
||||
consecutiveFailures = 0
|
||||
continue
|
||||
}
|
||||
consecutiveFailures++
|
||||
a.logger.Error("weixin getupdates api error",
|
||||
slog.String("config_id", cfg.ID),
|
||||
slog.Int("ret", resp.Ret),
|
||||
slog.Int("errcode", resp.ErrCode),
|
||||
slog.String("errmsg", resp.ErrMsg),
|
||||
)
|
||||
if consecutiveFailures >= maxConsecutiveFailures {
|
||||
consecutiveFailures = 0
|
||||
sleepCtx(ctx, backoffDelay)
|
||||
} else {
|
||||
sleepCtx(ctx, retryDelay)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
consecutiveFailures = 0
|
||||
|
||||
if resp.GetUpdatesBuf != "" {
|
||||
getUpdatesBuf = resp.GetUpdatesBuf
|
||||
}
|
||||
|
||||
for _, msg := range resp.Msgs {
|
||||
inbound, ok := buildInboundMessage(msg)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
// Cache context_token for outbound replies.
|
||||
if strings.TrimSpace(msg.ContextToken) != "" {
|
||||
cacheKey := cfg.ID + ":" + strings.TrimSpace(msg.FromUserID)
|
||||
a.contextCache.Put(cacheKey, msg.ContextToken)
|
||||
}
|
||||
|
||||
inbound.BotID = cfg.BotID
|
||||
|
||||
if err := handler(ctx, cfg, inbound); err != nil {
|
||||
a.logger.Error("weixin inbound handler error",
|
||||
slog.String("config_id", cfg.ID),
|
||||
slog.String("from", msg.FromUserID),
|
||||
slog.Any("error", err),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -- StreamSender (block-streaming: buffer deltas, send final as one message) --
|
||||
|
||||
func (a *WeixinAdapter) OpenStream(_ context.Context, cfg channel.ChannelConfig, target string, _ channel.StreamOptions) (channel.OutboundStream, error) {
|
||||
target = strings.TrimSpace(target)
|
||||
if target == "" {
|
||||
return nil, errors.New("weixin target is required")
|
||||
}
|
||||
return &weixinBlockStream{
|
||||
adapter: a,
|
||||
cfg: cfg,
|
||||
target: target,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// -- Sender --
|
||||
|
||||
func (a *WeixinAdapter) Send(ctx context.Context, cfg channel.ChannelConfig, msg channel.OutboundMessage) error {
|
||||
parsed, err := parseConfig(cfg.Credentials)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
target := strings.TrimSpace(msg.Target)
|
||||
if target == "" {
|
||||
return errors.New("weixin target is required")
|
||||
}
|
||||
|
||||
cacheKey := cfg.ID + ":" + target
|
||||
contextToken, ok := a.contextCache.Get(cacheKey)
|
||||
if !ok {
|
||||
return fmt.Errorf("weixin: no context_token cached for target %s (reply-only channel — message can only be sent after receiving an inbound message)", target)
|
||||
}
|
||||
|
||||
// Send attachments first if present (media + text in one flow).
|
||||
if len(msg.Message.Attachments) > 0 {
|
||||
return a.sendWithAttachments(ctx, parsed, cfg.BotID, target, contextToken, msg.Message)
|
||||
}
|
||||
|
||||
text := strings.TrimSpace(msg.Message.PlainText())
|
||||
if text == "" {
|
||||
return errors.New("weixin: message is empty")
|
||||
}
|
||||
return sendText(ctx, a.client, parsed, target, text, contextToken)
|
||||
}
|
||||
|
||||
func (a *WeixinAdapter) sendWithAttachments(ctx context.Context, cfg adapterConfig, botID, target, contextToken string, msg channel.Message) error {
|
||||
text := strings.TrimSpace(msg.PlainText())
|
||||
|
||||
for i, att := range msg.Attachments {
|
||||
caption := ""
|
||||
if i == 0 {
|
||||
caption = text
|
||||
}
|
||||
|
||||
r, err := a.openAttachment(ctx, botID, att)
|
||||
if err != nil {
|
||||
a.logger.Error("weixin: open attachment failed",
|
||||
slog.String("type", string(att.Type)),
|
||||
slog.Any("error", err),
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
switch att.Type {
|
||||
case channel.AttachmentImage, channel.AttachmentGIF:
|
||||
if err := sendImageFromReader(ctx, a.client, cfg, target, contextToken, caption, r, a.logger); err != nil {
|
||||
_ = r.Close()
|
||||
return err
|
||||
}
|
||||
case channel.AttachmentVideo:
|
||||
data, readErr := io.ReadAll(r)
|
||||
_ = r.Close()
|
||||
if readErr != nil {
|
||||
return fmt.Errorf("weixin: read video: %w", readErr)
|
||||
}
|
||||
if err := sendMediaBytes(ctx, a.client, cfg, target, contextToken, caption, data, UploadMediaVideo, ItemTypeVideo, a.logger); err != nil {
|
||||
return err
|
||||
}
|
||||
default:
|
||||
name := strings.TrimSpace(att.Name)
|
||||
if name == "" {
|
||||
name = "file"
|
||||
}
|
||||
if err := sendFileFromReader(ctx, a.client, cfg, target, contextToken, caption, name, r, a.logger); err != nil {
|
||||
_ = r.Close()
|
||||
return err
|
||||
}
|
||||
}
|
||||
_ = r.Close()
|
||||
}
|
||||
|
||||
// If there are attachments but no text was sent as caption and there is text, send separately.
|
||||
if len(msg.Attachments) == 0 && text != "" {
|
||||
return sendText(ctx, a.client, cfg, target, text, contextToken)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *WeixinAdapter) openAttachment(ctx context.Context, botID string, att channel.Attachment) (io.ReadCloser, error) {
|
||||
// Try content hash first (from media store).
|
||||
if strings.TrimSpace(att.ContentHash) != "" && a.assets != nil {
|
||||
r, _, err := a.assets.Open(ctx, botID, att.ContentHash)
|
||||
if err == nil {
|
||||
return r, nil
|
||||
}
|
||||
}
|
||||
// Try base64 data URL.
|
||||
if strings.TrimSpace(att.Base64) != "" {
|
||||
r, err := attachmentpkg.DecodeBase64(att.Base64, 100*1024*1024)
|
||||
if err == nil {
|
||||
data, readErr := io.ReadAll(r)
|
||||
if readErr == nil {
|
||||
return io.NopCloser(bytes.NewReader(data)), nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil, errors.New("weixin: cannot open attachment (no content_hash or base64)")
|
||||
}
|
||||
|
||||
// -- AttachmentResolver (for inbound media download/decrypt) --
|
||||
|
||||
func (*WeixinAdapter) ResolveAttachment(_ context.Context, cfg channel.ChannelConfig, attachment channel.Attachment) (channel.AttachmentPayload, error) {
|
||||
parsed, err := parseConfig(cfg.Credentials)
|
||||
if err != nil {
|
||||
return channel.AttachmentPayload{}, err
|
||||
}
|
||||
|
||||
encryptedQP := ""
|
||||
aesKey := ""
|
||||
if attachment.Metadata != nil {
|
||||
if v, ok := attachment.Metadata["encrypt_query_param"].(string); ok {
|
||||
encryptedQP = strings.TrimSpace(v)
|
||||
}
|
||||
if v, ok := attachment.Metadata["aes_key"].(string); ok {
|
||||
aesKey = strings.TrimSpace(v)
|
||||
}
|
||||
}
|
||||
if encryptedQP == "" {
|
||||
encryptedQP = strings.TrimSpace(attachment.PlatformKey)
|
||||
}
|
||||
if encryptedQP == "" {
|
||||
return channel.AttachmentPayload{}, errors.New("weixin: no encrypt_query_param for attachment")
|
||||
}
|
||||
|
||||
var data []byte
|
||||
if aesKey != "" {
|
||||
data, err = downloadAndDecrypt(parsed.CDNBaseURL, encryptedQP, aesKey)
|
||||
} else {
|
||||
data, err = downloadPlain(parsed.CDNBaseURL, encryptedQP)
|
||||
}
|
||||
if err != nil {
|
||||
return channel.AttachmentPayload{}, fmt.Errorf("weixin: download attachment: %w", err)
|
||||
}
|
||||
|
||||
mime := resolveMIME(attachment)
|
||||
return channel.AttachmentPayload{
|
||||
Reader: io.NopCloser(bytes.NewReader(data)),
|
||||
Mime: mime,
|
||||
Name: strings.TrimSpace(attachment.Name),
|
||||
Size: int64(len(data)),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func resolveMIME(att channel.Attachment) string {
|
||||
if strings.TrimSpace(att.Mime) != "" {
|
||||
return att.Mime
|
||||
}
|
||||
switch att.Type {
|
||||
case channel.AttachmentImage:
|
||||
return "image/jpeg"
|
||||
case channel.AttachmentVoice, channel.AttachmentAudio:
|
||||
return "audio/silk"
|
||||
case channel.AttachmentVideo:
|
||||
return "video/mp4"
|
||||
default:
|
||||
return "application/octet-stream"
|
||||
}
|
||||
}
|
||||
|
||||
// -- ProcessingStatusNotifier (typing indicator) --
|
||||
|
||||
func (a *WeixinAdapter) ProcessingStarted(ctx context.Context, cfg channel.ChannelConfig, _ channel.InboundMessage, info channel.ProcessingStatusInfo) (channel.ProcessingStatusHandle, error) {
|
||||
parsed, err := parseConfig(cfg.Credentials)
|
||||
if err != nil || !parsed.EnableTyping {
|
||||
return channel.ProcessingStatusHandle{}, nil
|
||||
}
|
||||
target := strings.TrimSpace(info.ReplyTarget)
|
||||
if target == "" {
|
||||
return channel.ProcessingStatusHandle{}, nil
|
||||
}
|
||||
|
||||
cacheKey := cfg.ID + ":" + target
|
||||
contextToken, _ := a.contextCache.Get(cacheKey)
|
||||
|
||||
configResp, err := a.client.GetConfig(ctx, parsed, target, contextToken)
|
||||
if err != nil || strings.TrimSpace(configResp.TypingTicket) == "" {
|
||||
return channel.ProcessingStatusHandle{}, nil
|
||||
}
|
||||
|
||||
_ = a.client.SendTyping(ctx, parsed, target, configResp.TypingTicket, TypingStatusTyping)
|
||||
return channel.ProcessingStatusHandle{Token: configResp.TypingTicket}, nil
|
||||
}
|
||||
|
||||
func (a *WeixinAdapter) ProcessingCompleted(ctx context.Context, cfg channel.ChannelConfig, _ channel.InboundMessage, info channel.ProcessingStatusInfo, handle channel.ProcessingStatusHandle) error {
|
||||
if strings.TrimSpace(handle.Token) == "" {
|
||||
return nil
|
||||
}
|
||||
parsed, err := parseConfig(cfg.Credentials)
|
||||
if err != nil || !parsed.EnableTyping {
|
||||
return nil
|
||||
}
|
||||
target := strings.TrimSpace(info.ReplyTarget)
|
||||
if target == "" {
|
||||
return nil
|
||||
}
|
||||
return a.client.SendTyping(ctx, parsed, target, handle.Token, TypingStatusCancel)
|
||||
}
|
||||
|
||||
func (a *WeixinAdapter) ProcessingFailed(ctx context.Context, cfg channel.ChannelConfig, msg channel.InboundMessage, info channel.ProcessingStatusInfo, handle channel.ProcessingStatusHandle, _ error) error {
|
||||
return a.ProcessingCompleted(ctx, cfg, msg, info, handle)
|
||||
}
|
||||
|
||||
// weixinBlockStream buffers streaming deltas and sends the final message as one Send call.
|
||||
type weixinBlockStream struct {
|
||||
adapter *WeixinAdapter
|
||||
cfg channel.ChannelConfig
|
||||
target string
|
||||
textBuilder strings.Builder
|
||||
attachments []channel.Attachment
|
||||
final *channel.Message
|
||||
closed bool
|
||||
}
|
||||
|
||||
func (s *weixinBlockStream) Push(_ context.Context, event channel.StreamEvent) error {
|
||||
if s.closed {
|
||||
return nil
|
||||
}
|
||||
switch event.Type {
|
||||
case channel.StreamEventDelta:
|
||||
if strings.TrimSpace(event.Delta) != "" && event.Phase != channel.StreamPhaseReasoning {
|
||||
s.textBuilder.WriteString(event.Delta)
|
||||
}
|
||||
case channel.StreamEventAttachment:
|
||||
s.attachments = append(s.attachments, event.Attachments...)
|
||||
case channel.StreamEventFinal:
|
||||
if event.Final != nil {
|
||||
msg := event.Final.Message
|
||||
s.final = &msg
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *weixinBlockStream) Close(ctx context.Context) error {
|
||||
if s.closed {
|
||||
return nil
|
||||
}
|
||||
s.closed = true
|
||||
|
||||
msg := channel.Message{Format: channel.MessageFormatPlain}
|
||||
if s.final != nil {
|
||||
msg = *s.final
|
||||
}
|
||||
if strings.TrimSpace(msg.Text) == "" {
|
||||
msg.Text = strings.TrimSpace(s.textBuilder.String())
|
||||
}
|
||||
if len(msg.Attachments) == 0 && len(s.attachments) > 0 {
|
||||
msg.Attachments = append(msg.Attachments, s.attachments...)
|
||||
}
|
||||
if msg.IsEmpty() {
|
||||
return nil
|
||||
}
|
||||
return s.adapter.Send(ctx, s.cfg, channel.OutboundMessage{
|
||||
Target: s.target,
|
||||
Message: msg,
|
||||
})
|
||||
}
|
||||
|
||||
// sleepCtx sleeps for the given duration or until the context is cancelled.
|
||||
func sleepCtx(ctx context.Context, d time.Duration) {
|
||||
t := time.NewTimer(d)
|
||||
defer t.Stop()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
case <-t.C:
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package weixin
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/memohai/memoh/internal/channel"
|
||||
)
|
||||
|
||||
func TestWeixinAdapter_Type(t *testing.T) {
|
||||
adapter := NewWeixinAdapter(nil)
|
||||
if adapter.Type() != Type {
|
||||
t.Errorf("Type() = %v, want %v", adapter.Type(), Type)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWeixinAdapter_Descriptor(t *testing.T) {
|
||||
adapter := NewWeixinAdapter(nil)
|
||||
desc := adapter.Descriptor()
|
||||
|
||||
if desc.Type != Type {
|
||||
t.Errorf("desc.Type = %v", desc.Type)
|
||||
}
|
||||
if desc.DisplayName != "WeChat" {
|
||||
t.Errorf("desc.DisplayName = %q", desc.DisplayName)
|
||||
}
|
||||
if !desc.Capabilities.Text {
|
||||
t.Error("should support text")
|
||||
}
|
||||
if !desc.Capabilities.Media {
|
||||
t.Error("should support media")
|
||||
}
|
||||
if !desc.Capabilities.Attachments {
|
||||
t.Error("should support attachments")
|
||||
}
|
||||
if len(desc.Capabilities.ChatTypes) != 1 || desc.Capabilities.ChatTypes[0] != channel.ConversationTypePrivate {
|
||||
t.Errorf("chat types = %v", desc.Capabilities.ChatTypes)
|
||||
}
|
||||
|
||||
if _, ok := desc.ConfigSchema.Fields["token"]; !ok {
|
||||
t.Error("config schema should have 'token' field")
|
||||
}
|
||||
if desc.ConfigSchema.Fields["token"].Type != channel.FieldSecret {
|
||||
t.Error("token field should be secret")
|
||||
}
|
||||
if !desc.ConfigSchema.Fields["token"].Required {
|
||||
t.Error("token field should be required")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWeixinAdapter_Interfaces(_ *testing.T) {
|
||||
adapter := NewWeixinAdapter(nil)
|
||||
|
||||
// Adapter
|
||||
var _ channel.Adapter = adapter
|
||||
// ConfigNormalizer
|
||||
var _ channel.ConfigNormalizer = adapter
|
||||
// TargetResolver
|
||||
var _ channel.TargetResolver = adapter
|
||||
// BindingMatcher
|
||||
var _ channel.BindingMatcher = adapter
|
||||
// Receiver
|
||||
var _ channel.Receiver = adapter
|
||||
// Sender
|
||||
var _ channel.Sender = adapter
|
||||
// AttachmentResolver
|
||||
var _ channel.AttachmentResolver = adapter
|
||||
// ProcessingStatusNotifier
|
||||
var _ channel.ProcessingStatusNotifier = adapter
|
||||
}
|
||||
@@ -201,7 +201,7 @@ func (s *Service) Search(ctx context.Context, query string, limit int) ([]Search
|
||||
}
|
||||
rows, err := s.queries.SearchChannelIdentities(ctx, sqlc.SearchChannelIdentitiesParams{
|
||||
Query: strings.TrimSpace(query),
|
||||
LimitCount: int32(limit),
|
||||
LimitCount: int32(limit), //nolint:gosec // limit is capped above
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
Generated
+168
-38
@@ -157,6 +157,9 @@ importers:
|
||||
pinia-plugin-persistedstate:
|
||||
specifier: ^4.7.1
|
||||
version: 4.7.1(pinia@3.0.4(typescript@5.9.3)(vue@3.5.26(typescript@5.9.3)))
|
||||
qrcode:
|
||||
specifier: ^1.5.4
|
||||
version: 1.5.4
|
||||
shiki:
|
||||
specifier: ^3.21.0
|
||||
version: 3.23.0
|
||||
@@ -200,6 +203,9 @@ importers:
|
||||
'@types/node':
|
||||
specifier: ^24.10.1
|
||||
version: 24.10.4
|
||||
'@types/qrcode':
|
||||
specifier: ^1.5.6
|
||||
version: 1.5.6
|
||||
'@vitejs/plugin-vue':
|
||||
specifier: ^6.0.5
|
||||
version: 6.0.5(vite@8.0.1(@types/node@24.10.4)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.26(typescript@5.9.3))
|
||||
@@ -220,7 +226,7 @@ importers:
|
||||
devDependencies:
|
||||
vitepress:
|
||||
specifier: ^1.6.0
|
||||
version: 1.6.4(@algolia/client-search@5.46.2)(@types/node@25.0.3)(axios@1.13.4)(lightningcss@1.32.0)(postcss@8.5.8)(search-insights@2.17.3)(typescript@5.9.3)
|
||||
version: 1.6.4(@algolia/client-search@5.46.2)(@types/node@25.0.3)(axios@1.13.4)(lightningcss@1.32.0)(postcss@8.5.8)(qrcode@1.5.4)(search-insights@2.17.3)(typescript@5.9.3)
|
||||
vue:
|
||||
specifier: ^3.5.0
|
||||
version: 3.5.26(typescript@5.9.3)
|
||||
@@ -251,13 +257,13 @@ importers:
|
||||
devDependencies:
|
||||
'@types/bun':
|
||||
specifier: latest
|
||||
version: 1.3.9
|
||||
version: 1.3.11
|
||||
'@types/node':
|
||||
specifier: ^22.10.5
|
||||
version: 22.19.5
|
||||
bun-types:
|
||||
specifier: latest
|
||||
version: 1.3.9
|
||||
version: 1.3.11
|
||||
tsup:
|
||||
specifier: ^8.4.0
|
||||
version: 8.5.1(@microsoft/api-extractor@7.55.2(@types/node@22.19.5))(jiti@2.6.1)(postcss@8.5.8)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2)
|
||||
@@ -1677,42 +1683,36 @@ packages:
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rolldown/binding-linux-arm64-musl@1.0.0-rc.10':
|
||||
resolution: {integrity: sha512-vELN+HNb2IzuzSBUOD4NHmP9yrGwl1DVM29wlQvx1OLSclL0NgVWnVDKl/8tEks79EFek/kebQKnNJkIAA4W2g==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.10':
|
||||
resolution: {integrity: sha512-ZqrufYTgzxbHwpqOjzSsb0UV/aV2TFIY5rP8HdsiPTv/CuAgCRjM6s9cYFwQ4CNH+hf9Y4erHW1GjZuZ7WoI7w==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rolldown/binding-linux-s390x-gnu@1.0.0-rc.10':
|
||||
resolution: {integrity: sha512-gSlmVS1FZJSRicA6IyjoRoKAFK7IIHBs7xJuHRSmjImqk3mPPWbR7RhbnfH2G6bcmMEllCt2vQ/7u9e6bBnByg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rolldown/binding-linux-x64-gnu@1.0.0-rc.10':
|
||||
resolution: {integrity: sha512-eOCKUpluKgfObT2pHjztnaWEIbUabWzk3qPZ5PuacuPmr4+JtQG4k2vGTY0H15edaTnicgU428XW/IH6AimcQw==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rolldown/binding-linux-x64-musl@1.0.0-rc.10':
|
||||
resolution: {integrity: sha512-Xdf2jQbfQowJnLcgYfD/m0Uu0Qj5OdxKallD78/IPPfzaiaI4KRAwZzHcKQ4ig1gtg1SuzC7jovNiM2TzQsBXA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rolldown/binding-openharmony-arm64@1.0.0-rc.10':
|
||||
resolution: {integrity: sha512-o1hYe8hLi1EY6jgPFyxQgQ1wcycX+qz8eEbVmot2hFkgUzPxy9+kF0u0NIQBeDq+Mko47AkaFFaChcvZa9UX9Q==}
|
||||
@@ -1786,67 +1786,56 @@ packages:
|
||||
resolution: {integrity: sha512-EHMUcDwhtdRGlXZsGSIuXSYwD5kOT9NVnx9sqzYiwAc91wfYOE1g1djOEDseZJKKqtHAHGwnGPQu3kytmfaXLQ==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-arm-musleabihf@4.54.0':
|
||||
resolution: {integrity: sha512-+pBrqEjaakN2ySv5RVrj/qLytYhPKEUwk+e3SFU5jTLHIcAtqh2rLrd/OkbNuHJpsBgxsD8ccJt5ga/SeG0JmA==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-arm64-gnu@4.54.0':
|
||||
resolution: {integrity: sha512-NSqc7rE9wuUaRBsBp5ckQ5CVz5aIRKCwsoa6WMF7G01sX3/qHUw/z4pv+D+ahL1EIKy6Enpcnz1RY8pf7bjwng==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-arm64-musl@4.54.0':
|
||||
resolution: {integrity: sha512-gr5vDbg3Bakga5kbdpqx81m2n9IX8M6gIMlQQIXiLTNeQW6CucvuInJ91EuCJ/JYvc+rcLLsDFcfAD1K7fMofg==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-loong64-gnu@4.54.0':
|
||||
resolution: {integrity: sha512-gsrtB1NA3ZYj2vq0Rzkylo9ylCtW/PhpLEivlgWe0bpgtX5+9j9EZa0wtZiCjgu6zmSeZWyI/e2YRX1URozpIw==}
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-ppc64-gnu@4.54.0':
|
||||
resolution: {integrity: sha512-y3qNOfTBStmFNq+t4s7Tmc9hW2ENtPg8FeUD/VShI7rKxNW7O4fFeaYbMsd3tpFlIg1Q8IapFgy7Q9i2BqeBvA==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-riscv64-gnu@4.54.0':
|
||||
resolution: {integrity: sha512-89sepv7h2lIVPsFma8iwmccN7Yjjtgz0Rj/Ou6fEqg3HDhpCa+Et+YSufy27i6b0Wav69Qv4WBNl3Rs6pwhebQ==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-riscv64-musl@4.54.0':
|
||||
resolution: {integrity: sha512-ZcU77ieh0M2Q8Ur7D5X7KvK+UxbXeDHwiOt/CPSBTI1fBmeDMivW0dPkdqkT4rOgDjrDDBUed9x4EgraIKoR2A==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-s390x-gnu@4.54.0':
|
||||
resolution: {integrity: sha512-2AdWy5RdDF5+4YfG/YesGDDtbyJlC9LHmL6rZw6FurBJ5n4vFGupsOBGfwMRjBYH7qRQowT8D/U4LoSvVwOhSQ==}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-x64-gnu@4.54.0':
|
||||
resolution: {integrity: sha512-WGt5J8Ij/rvyqpFexxk3ffKqqbLf9AqrTBbWDk7ApGUzaIs6V+s2s84kAxklFwmMF/vBNGrVdYgbblCOFFezMQ==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-x64-musl@4.54.0':
|
||||
resolution: {integrity: sha512-JzQmb38ATzHjxlPHuTH6tE7ojnMKM2kYNzt44LO/jJi8BpceEC8QuXYA908n8r3CNuG/B3BV8VR3Hi1rYtmPiw==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-openharmony-arm64@4.54.0':
|
||||
resolution: {integrity: sha512-huT3fd0iC7jigGh7n3q/+lfPcXxBi+om/Rs3yiFxjvSxbSB6aohDFXbWvlspaqjeOh+hx7DDHS+5Es5qRkWkZg==}
|
||||
@@ -2011,28 +2000,24 @@ packages:
|
||||
engines: {node: '>= 20'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@tailwindcss/oxide-linux-arm64-musl@4.2.2':
|
||||
resolution: {integrity: sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag==}
|
||||
engines: {node: '>= 20'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@tailwindcss/oxide-linux-x64-gnu@4.2.2':
|
||||
resolution: {integrity: sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg==}
|
||||
engines: {node: '>= 20'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@tailwindcss/oxide-linux-x64-musl@4.2.2':
|
||||
resolution: {integrity: sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==}
|
||||
engines: {node: '>= 20'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@tailwindcss/oxide-wasm32-wasi@4.2.2':
|
||||
resolution: {integrity: sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q==}
|
||||
@@ -2104,8 +2089,8 @@ packages:
|
||||
'@types/bun@1.3.10':
|
||||
resolution: {integrity: sha512-0+rlrUrOrTSskibryHbvQkDOWRJwJZqZlxrUs1u4oOoTln8+WIXBPmAuCF35SWB2z4Zl3E84Nl/D0P7803nigQ==}
|
||||
|
||||
'@types/bun@1.3.9':
|
||||
resolution: {integrity: sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw==}
|
||||
'@types/bun@1.3.11':
|
||||
resolution: {integrity: sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg==}
|
||||
|
||||
'@types/chai@5.2.3':
|
||||
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
|
||||
@@ -2239,6 +2224,9 @@ packages:
|
||||
'@types/node@25.0.3':
|
||||
resolution: {integrity: sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA==}
|
||||
|
||||
'@types/qrcode@1.5.6':
|
||||
resolution: {integrity: sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==}
|
||||
|
||||
'@types/trusted-types@2.0.7':
|
||||
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
|
||||
|
||||
@@ -2662,8 +2650,8 @@ packages:
|
||||
bun-types@1.3.10:
|
||||
resolution: {integrity: sha512-tcpfCCl6XWo6nCVnpcVrxQ+9AYN1iqMIzgrSKYMB/fjLtV2eyAVEg7AxQJuCq/26R6HpKWykQXuSOq/21RYcbg==}
|
||||
|
||||
bun-types@1.3.9:
|
||||
resolution: {integrity: sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg==}
|
||||
bun-types@1.3.11:
|
||||
resolution: {integrity: sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg==}
|
||||
|
||||
bundle-name@4.1.0:
|
||||
resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==}
|
||||
@@ -2695,6 +2683,10 @@ packages:
|
||||
resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
camelcase@5.3.1:
|
||||
resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
caniuse-lite@1.0.30001762:
|
||||
resolution: {integrity: sha512-PxZwGNvH7Ak8WX5iXzoK1KPZttBXNPuaOvI2ZYU7NrlM+d9Ov+TUvlLOBNGzVXAntMSMMlJPd+jY6ovrVjSmUw==}
|
||||
|
||||
@@ -2759,6 +2751,9 @@ packages:
|
||||
resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==}
|
||||
engines: {node: '>= 12'}
|
||||
|
||||
cliui@6.0.0:
|
||||
resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==}
|
||||
|
||||
clsx@2.1.1:
|
||||
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
|
||||
engines: {node: '>=6'}
|
||||
@@ -3026,6 +3021,10 @@ packages:
|
||||
supports-color:
|
||||
optional: true
|
||||
|
||||
decamelize@1.2.0:
|
||||
resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
decimal.js@10.6.0:
|
||||
resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==}
|
||||
|
||||
@@ -3072,6 +3071,9 @@ packages:
|
||||
resolution: {integrity: sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg==}
|
||||
engines: {node: '>=0.3.1'}
|
||||
|
||||
dijkstrajs@1.0.3:
|
||||
resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==}
|
||||
|
||||
dompurify@3.3.2:
|
||||
resolution: {integrity: sha512-6obghkliLdmKa56xdbLOpUZ43pAR6xFy1uOrxBaIDjT+yaRuuybLjGS9eVBoSR/UPU5fq3OXClEHLJNGvbxKpQ==}
|
||||
engines: {node: '>=20'}
|
||||
@@ -3295,6 +3297,10 @@ packages:
|
||||
resolution: {integrity: sha512-8kPJMIGz1Yt/aPEwOsrR97ZyZaD1Iqm8PClb1nYFclUCkBi0Ma5IsYNQzvSFS9ib51lWyIw5mIT9rWzI/xjpzA==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
find-up@4.1.0:
|
||||
resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
find-up@5.0.0:
|
||||
resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -3346,6 +3352,10 @@ packages:
|
||||
resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
get-caller-file@2.0.5:
|
||||
resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==}
|
||||
engines: {node: 6.* || 8.* || >= 10.*}
|
||||
|
||||
get-east-asian-width@1.4.0:
|
||||
resolution: {integrity: sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -3656,28 +3666,24 @@ packages:
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
lightningcss-linux-arm64-musl@1.32.0:
|
||||
resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
lightningcss-linux-x64-gnu@1.32.0:
|
||||
resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
lightningcss-linux-x64-musl@1.32.0:
|
||||
resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
lightningcss-win32-arm64-msvc@1.32.0:
|
||||
resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==}
|
||||
@@ -3713,6 +3719,10 @@ packages:
|
||||
resolution: {integrity: sha512-arhlxbFRmoQHl33a0Zkle/YWlmNwoyt6QNZEIJcqNbdrsix5Lvc4HyyI3EnwxTYlZYc32EbYrQ8SzEZ7dqgg9A==}
|
||||
engines: {node: '>=14'}
|
||||
|
||||
locate-path@5.0.0:
|
||||
resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
locate-path@6.0.0:
|
||||
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -3981,14 +3991,26 @@ packages:
|
||||
resolution: {integrity: sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
p-limit@2.3.0:
|
||||
resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
p-limit@3.1.0:
|
||||
resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
p-locate@4.1.0:
|
||||
resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
p-locate@5.0.0:
|
||||
resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
p-try@2.2.0:
|
||||
resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
package-manager-detector@1.6.0:
|
||||
resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==}
|
||||
|
||||
@@ -4080,6 +4102,10 @@ packages:
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
pngjs@5.0.0:
|
||||
resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==}
|
||||
engines: {node: '>=10.13.0'}
|
||||
|
||||
points-on-curve@0.2.0:
|
||||
resolution: {integrity: sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==}
|
||||
|
||||
@@ -4141,6 +4167,11 @@ packages:
|
||||
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
qrcode@1.5.4:
|
||||
resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==}
|
||||
engines: {node: '>=10.13.0'}
|
||||
hasBin: true
|
||||
|
||||
quansync@0.2.11:
|
||||
resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==}
|
||||
|
||||
@@ -4173,10 +4204,17 @@ packages:
|
||||
peerDependencies:
|
||||
vue: '>= 3.2.0'
|
||||
|
||||
require-directory@2.1.1:
|
||||
resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
require-from-string@2.0.2:
|
||||
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
require-main-filename@2.0.0:
|
||||
resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==}
|
||||
|
||||
resolve-from@4.0.0:
|
||||
resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
|
||||
engines: {node: '>=4'}
|
||||
@@ -4254,6 +4292,9 @@ packages:
|
||||
engines: {node: '>=10'}
|
||||
hasBin: true
|
||||
|
||||
set-blocking@2.0.0:
|
||||
resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==}
|
||||
|
||||
shebang-command@2.0.0:
|
||||
resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -4936,6 +4977,9 @@ packages:
|
||||
resolution: {integrity: sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
which-module@2.0.1:
|
||||
resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==}
|
||||
|
||||
which@2.0.2:
|
||||
resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
|
||||
engines: {node: '>= 8'}
|
||||
@@ -4990,6 +5034,9 @@ packages:
|
||||
xmlchars@2.2.0:
|
||||
resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==}
|
||||
|
||||
y18n@4.0.3:
|
||||
resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==}
|
||||
|
||||
yallist@3.1.1:
|
||||
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
|
||||
|
||||
@@ -5001,6 +5048,14 @@ packages:
|
||||
engines: {node: '>= 14.6'}
|
||||
hasBin: true
|
||||
|
||||
yargs-parser@18.1.3:
|
||||
resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
yargs@15.4.1:
|
||||
resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
yocto-queue@0.1.0:
|
||||
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -6588,9 +6643,9 @@ snapshots:
|
||||
dependencies:
|
||||
bun-types: 1.3.10
|
||||
|
||||
'@types/bun@1.3.9':
|
||||
'@types/bun@1.3.11':
|
||||
dependencies:
|
||||
bun-types: 1.3.9
|
||||
bun-types: 1.3.11
|
||||
|
||||
'@types/chai@5.2.3':
|
||||
dependencies:
|
||||
@@ -6751,6 +6806,10 @@ snapshots:
|
||||
dependencies:
|
||||
undici-types: 7.16.0
|
||||
|
||||
'@types/qrcode@1.5.6':
|
||||
dependencies:
|
||||
'@types/node': 24.10.4
|
||||
|
||||
'@types/trusted-types@2.0.7':
|
||||
optional: true
|
||||
|
||||
@@ -7078,7 +7137,7 @@ snapshots:
|
||||
'@vueuse/shared': 14.1.0(vue@3.5.26(typescript@5.9.3))
|
||||
vue: 3.5.26(typescript@5.9.3)
|
||||
|
||||
'@vueuse/integrations@12.8.2(axios@1.13.4)(focus-trap@7.8.0)(typescript@5.9.3)':
|
||||
'@vueuse/integrations@12.8.2(axios@1.13.4)(focus-trap@7.8.0)(qrcode@1.5.4)(typescript@5.9.3)':
|
||||
dependencies:
|
||||
'@vueuse/core': 12.8.2(typescript@5.9.3)
|
||||
'@vueuse/shared': 12.8.2(typescript@5.9.3)
|
||||
@@ -7086,6 +7145,7 @@ snapshots:
|
||||
optionalDependencies:
|
||||
axios: 1.13.4
|
||||
focus-trap: 7.8.0
|
||||
qrcode: 1.5.4
|
||||
transitivePeerDependencies:
|
||||
- typescript
|
||||
|
||||
@@ -7264,7 +7324,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@types/node': 24.10.4
|
||||
|
||||
bun-types@1.3.9:
|
||||
bun-types@1.3.11:
|
||||
dependencies:
|
||||
'@types/node': 24.10.4
|
||||
|
||||
@@ -7302,6 +7362,8 @@ snapshots:
|
||||
|
||||
callsites@3.1.0: {}
|
||||
|
||||
camelcase@5.3.1: {}
|
||||
|
||||
caniuse-lite@1.0.30001762: {}
|
||||
|
||||
ccount@2.0.1: {}
|
||||
@@ -7361,6 +7423,12 @@ snapshots:
|
||||
|
||||
cli-width@4.1.0: {}
|
||||
|
||||
cliui@6.0.0:
|
||||
dependencies:
|
||||
string-width: 4.2.3
|
||||
strip-ansi: 6.0.1
|
||||
wrap-ansi: 6.2.0
|
||||
|
||||
clsx@2.1.1: {}
|
||||
|
||||
color-convert@2.0.1:
|
||||
@@ -7634,6 +7702,8 @@ snapshots:
|
||||
dependencies:
|
||||
ms: 2.1.3
|
||||
|
||||
decamelize@1.2.0: {}
|
||||
|
||||
decimal.js@10.6.0:
|
||||
optional: true
|
||||
|
||||
@@ -7669,6 +7739,8 @@ snapshots:
|
||||
|
||||
diff@8.0.2: {}
|
||||
|
||||
dijkstrajs@1.0.3: {}
|
||||
|
||||
dompurify@3.3.2:
|
||||
optionalDependencies:
|
||||
'@types/trusted-types': 2.0.7
|
||||
@@ -7992,6 +8064,11 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
find-up@4.1.0:
|
||||
dependencies:
|
||||
locate-path: 5.0.0
|
||||
path-exists: 4.0.0
|
||||
|
||||
find-up@5.0.0:
|
||||
dependencies:
|
||||
locate-path: 6.0.0
|
||||
@@ -8042,6 +8119,8 @@ snapshots:
|
||||
|
||||
gensync@1.0.0-beta.2: {}
|
||||
|
||||
get-caller-file@2.0.5: {}
|
||||
|
||||
get-east-asian-width@1.4.0: {}
|
||||
|
||||
get-intrinsic@1.3.0:
|
||||
@@ -8386,6 +8465,10 @@ snapshots:
|
||||
pkg-types: 2.3.0
|
||||
quansync: 0.2.11
|
||||
|
||||
locate-path@5.0.0:
|
||||
dependencies:
|
||||
p-locate: 4.1.0
|
||||
|
||||
locate-path@6.0.0:
|
||||
dependencies:
|
||||
p-locate: 5.0.0
|
||||
@@ -8672,14 +8755,24 @@ snapshots:
|
||||
string-width: 7.2.0
|
||||
strip-ansi: 7.1.2
|
||||
|
||||
p-limit@2.3.0:
|
||||
dependencies:
|
||||
p-try: 2.2.0
|
||||
|
||||
p-limit@3.1.0:
|
||||
dependencies:
|
||||
yocto-queue: 0.1.0
|
||||
|
||||
p-locate@4.1.0:
|
||||
dependencies:
|
||||
p-limit: 2.3.0
|
||||
|
||||
p-locate@5.0.0:
|
||||
dependencies:
|
||||
p-limit: 3.1.0
|
||||
|
||||
p-try@2.2.0: {}
|
||||
|
||||
package-manager-detector@1.6.0: {}
|
||||
|
||||
parent-module@1.0.1:
|
||||
@@ -8748,6 +8841,8 @@ snapshots:
|
||||
optionalDependencies:
|
||||
fsevents: 2.3.2
|
||||
|
||||
pngjs@5.0.0: {}
|
||||
|
||||
points-on-curve@0.2.0: {}
|
||||
|
||||
points-on-path@0.2.1:
|
||||
@@ -8796,6 +8891,12 @@ snapshots:
|
||||
|
||||
punycode@2.3.1: {}
|
||||
|
||||
qrcode@1.5.4:
|
||||
dependencies:
|
||||
dijkstrajs: 1.0.3
|
||||
pngjs: 5.0.0
|
||||
yargs: 15.4.1
|
||||
|
||||
quansync@0.2.11: {}
|
||||
|
||||
rc9@2.1.2:
|
||||
@@ -8839,8 +8940,12 @@ snapshots:
|
||||
- '@vue/composition-api'
|
||||
- typescript
|
||||
|
||||
require-directory@2.1.1: {}
|
||||
|
||||
require-from-string@2.0.2: {}
|
||||
|
||||
require-main-filename@2.0.0: {}
|
||||
|
||||
resolve-from@4.0.0: {}
|
||||
|
||||
resolve-from@5.0.0: {}
|
||||
@@ -8945,6 +9050,8 @@ snapshots:
|
||||
|
||||
semver@7.7.3: {}
|
||||
|
||||
set-blocking@2.0.0: {}
|
||||
|
||||
shebang-command@2.0.0:
|
||||
dependencies:
|
||||
shebang-regex: 3.0.0
|
||||
@@ -9430,7 +9537,7 @@ snapshots:
|
||||
tsx: 4.21.0
|
||||
yaml: 2.8.2
|
||||
|
||||
vitepress@1.6.4(@algolia/client-search@5.46.2)(@types/node@25.0.3)(axios@1.13.4)(lightningcss@1.32.0)(postcss@8.5.8)(search-insights@2.17.3)(typescript@5.9.3):
|
||||
vitepress@1.6.4(@algolia/client-search@5.46.2)(@types/node@25.0.3)(axios@1.13.4)(lightningcss@1.32.0)(postcss@8.5.8)(qrcode@1.5.4)(search-insights@2.17.3)(typescript@5.9.3):
|
||||
dependencies:
|
||||
'@docsearch/css': 3.8.2
|
||||
'@docsearch/js': 3.8.2(@algolia/client-search@5.46.2)(search-insights@2.17.3)
|
||||
@@ -9443,7 +9550,7 @@ snapshots:
|
||||
'@vue/devtools-api': 7.7.9
|
||||
'@vue/shared': 3.5.26
|
||||
'@vueuse/core': 12.8.2(typescript@5.9.3)
|
||||
'@vueuse/integrations': 12.8.2(axios@1.13.4)(focus-trap@7.8.0)(typescript@5.9.3)
|
||||
'@vueuse/integrations': 12.8.2(axios@1.13.4)(focus-trap@7.8.0)(qrcode@1.5.4)(typescript@5.9.3)
|
||||
focus-trap: 7.8.0
|
||||
mark.js: 8.11.1
|
||||
minisearch: 7.2.0
|
||||
@@ -9608,6 +9715,8 @@ snapshots:
|
||||
webidl-conversions: 8.0.1
|
||||
optional: true
|
||||
|
||||
which-module@2.0.1: {}
|
||||
|
||||
which@2.0.2:
|
||||
dependencies:
|
||||
isexe: 2.0.0
|
||||
@@ -9649,12 +9758,33 @@ snapshots:
|
||||
xmlchars@2.2.0:
|
||||
optional: true
|
||||
|
||||
y18n@4.0.3: {}
|
||||
|
||||
yallist@3.1.1: {}
|
||||
|
||||
yallist@4.0.0: {}
|
||||
|
||||
yaml@2.8.2: {}
|
||||
|
||||
yargs-parser@18.1.3:
|
||||
dependencies:
|
||||
camelcase: 5.3.1
|
||||
decamelize: 1.2.0
|
||||
|
||||
yargs@15.4.1:
|
||||
dependencies:
|
||||
cliui: 6.0.0
|
||||
decamelize: 1.2.0
|
||||
find-up: 4.1.0
|
||||
get-caller-file: 2.0.5
|
||||
require-directory: 2.1.1
|
||||
require-main-filename: 2.0.0
|
||||
set-blocking: 2.0.0
|
||||
string-width: 4.2.3
|
||||
which-module: 2.0.1
|
||||
y18n: 4.0.3
|
||||
yargs-parser: 18.1.3
|
||||
|
||||
yocto-queue@0.1.0: {}
|
||||
|
||||
yoctocolors-cjs@2.1.3: {}
|
||||
|
||||
+156
@@ -4949,6 +4949,111 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"/bots/{id}/channel/weixin/qr/poll": {
|
||||
"post": {
|
||||
"description": "Long-poll the QR code scan status. On confirmed, auto-saves credentials.",
|
||||
"tags": [
|
||||
"bots"
|
||||
],
|
||||
"summary": "Poll WeChat QR login status",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Bot ID",
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"description": "QR code to poll",
|
||||
"name": "payload",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/weixin.QRPollRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/weixin.QRPollResponse"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/bots/{id}/channel/weixin/qr/start": {
|
||||
"post": {
|
||||
"description": "Fetch a QR code from WeChat for scanning",
|
||||
"tags": [
|
||||
"bots"
|
||||
],
|
||||
"summary": "Start WeChat QR login",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Bot ID",
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"description": "Optional base URL override",
|
||||
"name": "payload",
|
||||
"in": "body",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/weixin.QRStartRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/weixin.QRStartResponse"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/bots/{id}/channel/{platform}": {
|
||||
"get": {
|
||||
"description": "Get bot channel configuration",
|
||||
@@ -12398,6 +12503,57 @@ const docTemplate = `{
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"weixin.QRPollRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"baseUrl": {
|
||||
"type": "string"
|
||||
},
|
||||
"qr_code": {
|
||||
"type": "string"
|
||||
},
|
||||
"routeTag": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"weixin.QRPollResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"message": {
|
||||
"type": "string"
|
||||
},
|
||||
"status": {
|
||||
"description": "wait, scaned, confirmed, expired",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"weixin.QRStartRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"baseUrl": {
|
||||
"type": "string"
|
||||
},
|
||||
"routeTag": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"weixin.QRStartResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"message": {
|
||||
"type": "string"
|
||||
},
|
||||
"qr_code": {
|
||||
"type": "string"
|
||||
},
|
||||
"qr_code_url": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`
|
||||
|
||||
@@ -4940,6 +4940,111 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/bots/{id}/channel/weixin/qr/poll": {
|
||||
"post": {
|
||||
"description": "Long-poll the QR code scan status. On confirmed, auto-saves credentials.",
|
||||
"tags": [
|
||||
"bots"
|
||||
],
|
||||
"summary": "Poll WeChat QR login status",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Bot ID",
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"description": "QR code to poll",
|
||||
"name": "payload",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/weixin.QRPollRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/weixin.QRPollResponse"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/bots/{id}/channel/weixin/qr/start": {
|
||||
"post": {
|
||||
"description": "Fetch a QR code from WeChat for scanning",
|
||||
"tags": [
|
||||
"bots"
|
||||
],
|
||||
"summary": "Start WeChat QR login",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Bot ID",
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"description": "Optional base URL override",
|
||||
"name": "payload",
|
||||
"in": "body",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/weixin.QRStartRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/weixin.QRStartResponse"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/bots/{id}/channel/{platform}": {
|
||||
"get": {
|
||||
"description": "Get bot channel configuration",
|
||||
@@ -12389,6 +12494,57 @@
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"weixin.QRPollRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"baseUrl": {
|
||||
"type": "string"
|
||||
},
|
||||
"qr_code": {
|
||||
"type": "string"
|
||||
},
|
||||
"routeTag": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"weixin.QRPollResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"message": {
|
||||
"type": "string"
|
||||
},
|
||||
"status": {
|
||||
"description": "wait, scaned, confirmed, expired",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"weixin.QRStartRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"baseUrl": {
|
||||
"type": "string"
|
||||
},
|
||||
"routeTag": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"weixin.QRStartResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"message": {
|
||||
"type": "string"
|
||||
},
|
||||
"qr_code": {
|
||||
"type": "string"
|
||||
},
|
||||
"qr_code_url": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2531,6 +2531,39 @@ definitions:
|
||||
name:
|
||||
type: string
|
||||
type: object
|
||||
weixin.QRPollRequest:
|
||||
properties:
|
||||
baseUrl:
|
||||
type: string
|
||||
qr_code:
|
||||
type: string
|
||||
routeTag:
|
||||
type: string
|
||||
type: object
|
||||
weixin.QRPollResponse:
|
||||
properties:
|
||||
message:
|
||||
type: string
|
||||
status:
|
||||
description: wait, scaned, confirmed, expired
|
||||
type: string
|
||||
type: object
|
||||
weixin.QRStartRequest:
|
||||
properties:
|
||||
baseUrl:
|
||||
type: string
|
||||
routeTag:
|
||||
type: string
|
||||
type: object
|
||||
weixin.QRStartResponse:
|
||||
properties:
|
||||
message:
|
||||
type: string
|
||||
qr_code:
|
||||
type: string
|
||||
qr_code_url:
|
||||
type: string
|
||||
type: object
|
||||
info:
|
||||
contact: {}
|
||||
title: Memoh API
|
||||
@@ -6072,6 +6105,75 @@ paths:
|
||||
summary: Update bot channel status
|
||||
tags:
|
||||
- bots
|
||||
/bots/{id}/channel/weixin/qr/poll:
|
||||
post:
|
||||
description: Long-poll the QR code scan status. On confirmed, auto-saves credentials.
|
||||
parameters:
|
||||
- description: Bot ID
|
||||
in: path
|
||||
name: id
|
||||
required: true
|
||||
type: string
|
||||
- description: QR code to poll
|
||||
in: body
|
||||
name: payload
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/weixin.QRPollRequest'
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
$ref: '#/definitions/weixin.QRPollResponse'
|
||||
"400":
|
||||
description: Bad Request
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
summary: Poll WeChat QR login status
|
||||
tags:
|
||||
- bots
|
||||
/bots/{id}/channel/weixin/qr/start:
|
||||
post:
|
||||
description: Fetch a QR code from WeChat for scanning
|
||||
parameters:
|
||||
- description: Bot ID
|
||||
in: path
|
||||
name: id
|
||||
required: true
|
||||
type: string
|
||||
- description: Optional base URL override
|
||||
in: body
|
||||
name: payload
|
||||
schema:
|
||||
$ref: '#/definitions/weixin.QRStartRequest'
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
$ref: '#/definitions/weixin.QRStartResponse'
|
||||
"400":
|
||||
description: Bad Request
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
summary: Start WeChat QR login
|
||||
tags:
|
||||
- bots
|
||||
/bots/{id}/checks:
|
||||
get:
|
||||
description: Evaluate bot attached resource checks in runtime
|
||||
|
||||
Reference in New Issue
Block a user