mirror of
https://github.com/memohai/Memoh.git
synced 2026-04-27 07:16:19 +09:00
feat: matrix support (part 1) (#242)
* feat(channel): add Matrix adapter support * fix(channel): prevent reasoning leaks in Matrix replies * fix(channel): persist Matrix sync cursors * fix(channel): improve Matrix markdown rendering * fix(channel): support Matrix attachments and multimodal history * fix(channel): expand Matrix reply media context * fix(handlers): allow media downloads for chat-access bots * fix(channel): classify Matrix DMs as direct chats * fix(channel): auto-join Matrix room invites * fix(channel): resolve Matrix room aliases for outbound send * fix(web): use Matrix brand icon in channel badges Replace the generic Matrix hashtag badge with the official brand asset so channel badges feel recognizable and fit the circular mask cleanly. * fix(channel): add Matrix room whitelist controls Let Matrix bots decide whether to auto-join invites and restrict inbound activity to allowed rooms or aliases. Expose the new controls in the web settings UI with line-based whitelist input so access rules stay explicit. * fix(channel): stabilize Matrix multimodal follow-ups and settings * fix(flow): avoid gosec panic on byte decoding * fix: fix golangci-lint * fix(channel): remove Matrix built-in ACL * fix(channel): preserve Matrix image captions * fix(channel): validate Matrix homeserver and sync access Fail Matrix connections early when the homeserver, access token, or /sync capability is misconfigured so bot health checks surface actionable errors. * fix(channel): preserve optional toggles and relax Matrix startup validation * fix(channel): tighten Matrix mention fallback parsing * fix(flow): skip structured assistant tool-call outputs * fix(flow): resolve merged resolver duplication Keep the internal agent resolver implementation after merging main so split helper files do not redeclare flow symbols. Restore user message normalization in sanitize and persistence paths to keep flow tests and command packages building. * fix(flow): remove unused merged resolver helper Drop the leftover truncate helper and import from the resolver merge fix so golangci-lint passes again without affecting flow behavior. --------- Co-authored-by: Acbox Liu <acbox0328@gmail.com>
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-110 -110 740 740" fill="none">
|
||||
<path fill="#000" d="M13.7 11.9v496.2h35.7V520H0V0h49.4v11.9H13.7Z"/>
|
||||
<path fill="#000" d="M166.3 169.2v25.1h.7c6.7-9.6 14.8-17 24.2-22.2 9.4-5.3 20.3-7.9 32.5-7.9 11.7 0 22.4 2.3 32.1 6.8 9.7 4.5 17 12.6 22.1 24 5.5-8.1 13-15.3 22.4-21.5 9.4-6.2 20.6-9.3 33.5-9.3 9.8 0 18.9 1.2 27.3 3.6 8.4 2.4 15.5 6.2 21.5 11.5 6 5.3 10.6 12.1 14 20.6 3.3 8.5 5 18.7 5 30.7v124.1h-50.9V249.6c0-6.2-.2-12.1-.7-17.6-.5-5.5-1.8-10.3-3.9-14.3-2.2-4.1-5.3-7.3-9.5-9.7-4.2-2.4-9.9-3.6-17-3.6-7.2 0-13 1.4-17.4 4.1-4.4 2.8-7.9 6.3-10.4 10.8-2.5 4.4-4.2 9.4-5 15.1-.8 5.6-1.3 11.3-1.3 17v103.3h-50.9v-104c0-5.5-.1-10.9-.4-16.3-.2-5.4-1.3-10.3-3.1-14.9-1.8-4.5-4.8-8.2-9-10.9-4.2-2.7-10.3-4.1-18.5-4.1-2.4 0-5.6.5-9.5 1.6-3.9 1.1-7.8 3.1-11.5 6.1-3.7 3-6.9 7.3-9.5 12.9-2.6 5.6-3.9 13-3.9 22.1v107.6h-50.9V169.2h50.6Z"/>
|
||||
<path fill="#000" d="M506.3 508.1V11.9h-35.7V0H520v520h-49.4v-11.9h35.7Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 970 B |
@@ -887,11 +887,16 @@
|
||||
"webhookCallback": "WebHook Callback URL",
|
||||
"webhookCallbackHint": "Use this URL as the event subscription request URL in Feishu/Lark.",
|
||||
"webhookCallbackPending": "Save this platform configuration to generate the callback URL.",
|
||||
"showSecretField": "Show {field}",
|
||||
"hideSecretField": "Hide {field}",
|
||||
"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",
|
||||
"types": {
|
||||
"feishu": "Feishu",
|
||||
"discord": "Discord",
|
||||
"qq": "QQ",
|
||||
"matrix": "Matrix",
|
||||
"telegram": "Telegram",
|
||||
"web": "Web",
|
||||
"local": "Local"
|
||||
@@ -900,6 +905,7 @@
|
||||
"feishu": "FS",
|
||||
"discord": "DC",
|
||||
"qq": "QQ",
|
||||
"matrix": "MX",
|
||||
"telegram": "TG",
|
||||
"web": "Web",
|
||||
"local": "CLI"
|
||||
|
||||
@@ -883,11 +883,16 @@
|
||||
"webhookCallback": "WebHook 回调地址",
|
||||
"webhookCallbackHint": "将该地址配置到飞书/Lark 事件订阅的请求 URL。",
|
||||
"webhookCallbackPending": "保存平台配置后会生成回调地址。",
|
||||
"showSecretField": "显示{field}",
|
||||
"hideSecretField": "隐藏{field}",
|
||||
"feishuWebhookSecurityHint": "出于安全考虑,Webhook 模式必须配置 Encrypt Key 或 Verification Token 之一;未受保护的回调地址不应直接暴露在公网上。",
|
||||
"feishuWebhookSecretRequired": "出于安全考虑,请至少配置 Encrypt Key 或 Verification Token 之一。",
|
||||
"noAvailableTypes": "所有平台类型均已配置",
|
||||
"types": {
|
||||
"feishu": "飞书",
|
||||
"discord": "Discord",
|
||||
"qq": "QQ",
|
||||
"matrix": "Matrix",
|
||||
"telegram": "Telegram",
|
||||
"web": "Web",
|
||||
"local": "本地"
|
||||
@@ -896,6 +901,7 @@
|
||||
"feishu": "飞",
|
||||
"discord": "DC",
|
||||
"qq": "QQ",
|
||||
"matrix": "MX",
|
||||
"telegram": "TG",
|
||||
"web": "Web",
|
||||
"local": "CLI"
|
||||
|
||||
@@ -199,10 +199,19 @@ const selectedItem = computed(() =>
|
||||
allChannels.value.find((c) => c.meta.type === selectedType.value) ?? null,
|
||||
)
|
||||
|
||||
watch(configuredChannels, (list) => {
|
||||
if (list.length > 0 && !selectedType.value) {
|
||||
selectedType.value = list[0].meta.type
|
||||
watch(allChannels, (list) => {
|
||||
if (list.length === 0) {
|
||||
selectedType.value = null
|
||||
return
|
||||
}
|
||||
|
||||
const current = selectedType.value
|
||||
if (current && list.some((item) => item.meta.type === current)) {
|
||||
return
|
||||
}
|
||||
|
||||
const configured = list.find((item) => item.configured)
|
||||
selectedType.value = configured?.meta.type ?? list[0].meta.type
|
||||
}, { immediate: true })
|
||||
|
||||
function addChannel(type: string) {
|
||||
@@ -214,6 +223,7 @@ function channelIcon(type: string): string {
|
||||
const icons: Record<string, string> = {
|
||||
qq: 'QQ',
|
||||
telegram: 'TG',
|
||||
matrix: 'MX',
|
||||
feishu: '飞',
|
||||
}
|
||||
return icons[type] ?? type.slice(0, 2).toUpperCase()
|
||||
@@ -223,6 +233,7 @@ function channelBadgeClass(type: string): string {
|
||||
const classes: Record<string, string> = {
|
||||
qq: 'bg-sky-100 text-sky-700 dark:bg-sky-900 dark:text-sky-300',
|
||||
telegram: 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300',
|
||||
matrix: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900 dark:text-emerald-300',
|
||||
feishu: 'bg-indigo-100 text-indigo-700 dark:bg-indigo-900 dark:text-indigo-300',
|
||||
}
|
||||
return classes[type] ?? 'bg-secondary text-secondary-foreground'
|
||||
|
||||
@@ -121,14 +121,15 @@
|
||||
>
|
||||
<Input
|
||||
:id="`channel-field-${key}`"
|
||||
v-model="form.credentials[key]"
|
||||
:model-value="credentialStringValue(key)"
|
||||
:type="visibleSecrets[key] ? 'text' : 'password'"
|
||||
:placeholder="field.example ? String(field.example) : ''"
|
||||
@update:model-value="(val) => setCredentialStringValue(key, val)"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
:aria-label="`${visibleSecrets[key] ? 'Hide' : 'Show'} ${field.title || key}`"
|
||||
:aria-label="secretToggleLabel(key, field.title || key)"
|
||||
:aria-pressed="!!visibleSecrets[key]"
|
||||
@click="visibleSecrets[key] = !visibleSecrets[key]"
|
||||
>
|
||||
@@ -150,9 +151,10 @@
|
||||
<Input
|
||||
v-else-if="field.type === 'number'"
|
||||
:id="`channel-field-${key}`"
|
||||
v-model.number="form.credentials[key]"
|
||||
:model-value="credentialNumberValue(key)"
|
||||
type="number"
|
||||
:placeholder="field.example ? String(field.example) : ''"
|
||||
@update:model-value="(val) => setCredentialNumberValue(key, val)"
|
||||
/>
|
||||
|
||||
<!-- Enum field -->
|
||||
@@ -179,9 +181,10 @@
|
||||
<Input
|
||||
v-else
|
||||
:id="`channel-field-${key}`"
|
||||
v-model="form.credentials[key]"
|
||||
:model-value="credentialStringValue(key)"
|
||||
type="text"
|
||||
:placeholder="field.example ? String(field.example) : ''"
|
||||
@update:model-value="(val) => setCredentialStringValue(key, val)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -268,6 +271,7 @@ const emit = defineEmits<{
|
||||
|
||||
const { t } = useI18n()
|
||||
const botIdRef = computed(() => props.botId)
|
||||
const platformType = computed(() => String(props.channelItem.meta.type || '').trim())
|
||||
const queryCache = useQueryCache()
|
||||
const { mutateAsync: upsertChannel, isLoading } = useMutation({
|
||||
mutation: async ({ platform, data }: { platform: string; data: ChannelUpsertConfigRequest }) => {
|
||||
@@ -311,7 +315,8 @@ const visibleSecrets = reactive<Record<string, boolean>>({})
|
||||
// Schema fields sorted: required first. Exclude "status"/"disabled" from credential form.
|
||||
const orderedFields = computed(() => {
|
||||
const fields = props.channelItem.meta.config_schema?.fields ?? {}
|
||||
const entries = Object.entries(fields).filter(([key]) => key !== 'status' && key !== 'disabled')
|
||||
const hiddenFields = new Set(['status', 'disabled'])
|
||||
const entries = Object.entries(fields).filter(([key]) => !hiddenFields.has(key))
|
||||
entries.sort(([, a], [, b]) => {
|
||||
if (a.required && !b.required) return -1
|
||||
if (!a.required && b.required) return 1
|
||||
@@ -342,8 +347,17 @@ function initForm() {
|
||||
const existingCredentials = props.channelItem.config?.credentials ?? {}
|
||||
|
||||
const creds: Record<string, unknown> = {}
|
||||
for (const key of Object.keys(schema)) {
|
||||
creds[key] = existingCredentials[key] ?? ''
|
||||
for (const [key, field] of Object.entries(schema)) {
|
||||
const existingValue = existingCredentials[key]
|
||||
if (existingValue !== undefined) {
|
||||
creds[key] = existingValue
|
||||
continue
|
||||
}
|
||||
if (field.type === 'bool') {
|
||||
creds[key] = undefined
|
||||
continue
|
||||
}
|
||||
creds[key] = ''
|
||||
}
|
||||
form.credentials = creds
|
||||
form.disabled = props.channelItem.config?.disabled ?? false
|
||||
@@ -393,13 +407,48 @@ function buildCredentials(): Record<string, unknown> {
|
||||
return credentials
|
||||
}
|
||||
|
||||
function credentialStringValue(key: string): string | number | undefined {
|
||||
const value = form.credentials[key]
|
||||
if (typeof value === 'string' || typeof value === 'number') {
|
||||
return value
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function setCredentialStringValue(key: string, value: string | number) {
|
||||
form.credentials[key] = value
|
||||
}
|
||||
|
||||
function credentialNumberValue(key: string): string | number | undefined {
|
||||
const value = form.credentials[key]
|
||||
if (typeof value === 'number' || typeof value === 'string') {
|
||||
return value
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function setCredentialNumberValue(key: string, value: string | number) {
|
||||
if (value === '') {
|
||||
form.credentials[key] = ''
|
||||
return
|
||||
}
|
||||
const numericValue = typeof value === 'number' ? value : Number(value)
|
||||
form.credentials[key] = Number.isNaN(numericValue) ? '' : numericValue
|
||||
}
|
||||
|
||||
function secretToggleLabel(key: string, label: string): string {
|
||||
return visibleSecrets[key]
|
||||
? t('bots.channels.hideSecretField', { field: label })
|
||||
: t('bots.channels.showSecretField', { field: label })
|
||||
}
|
||||
|
||||
async function saveChannel(disabled: boolean, nextAction: 'save' | 'toggle') {
|
||||
if (!validateRequired()) return
|
||||
if (!validateFeishuWebhookSecrets()) return
|
||||
action.value = nextAction
|
||||
try {
|
||||
const result = await upsertChannel({
|
||||
platform: props.channelItem.meta.type,
|
||||
platform: platformType.value,
|
||||
data: {
|
||||
credentials: buildCredentials(),
|
||||
disabled,
|
||||
@@ -438,7 +487,7 @@ async function handleToggleDisabled() {
|
||||
if (!nextDisabled && !validateFeishuWebhookSecrets()) return
|
||||
action.value = 'toggle'
|
||||
const result = await updateChannelStatus({
|
||||
platform: props.channelItem.meta.type,
|
||||
platform: platformType.value,
|
||||
disabled: nextDisabled,
|
||||
})
|
||||
form.disabled = !!result?.disabled
|
||||
@@ -456,7 +505,7 @@ async function handleDelete() {
|
||||
action.value = 'delete'
|
||||
try {
|
||||
await deleteBotsByIdChannelByPlatform({
|
||||
path: { id: botIdRef.value, platform: props.channelItem.meta.type },
|
||||
path: { id: botIdRef.value, platform: platformType.value },
|
||||
throwOnError: true,
|
||||
})
|
||||
lastSavedConfigId.value = ''
|
||||
|
||||
@@ -292,7 +292,7 @@ function platformLabel(platformKey: string): string {
|
||||
}
|
||||
|
||||
const platformOptions = computed(() => {
|
||||
const options = new Set<string>(['telegram', 'feishu', 'discord', 'qq'])
|
||||
const options = new Set<string>(['telegram', 'feishu', 'discord', 'qq', 'matrix'])
|
||||
for (const identity of identities.value) {
|
||||
const platform = identity.channel.trim()
|
||||
if (platform) {
|
||||
|
||||
@@ -1,17 +1,13 @@
|
||||
/**
|
||||
* Local channel icons under public/channels/ (only Feishu for now).
|
||||
* 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'],
|
||||
|
||||
Reference in New Issue
Block a user