From 1c15eb214659c9bd63c52ce354a96264d02685f7 Mon Sep 17 00:00:00 2001 From: BBQ Date: Thu, 12 Feb 2026 20:52:34 +0800 Subject: [PATCH] refactor(core): restructure conversation/channel/message domains and modernize deployment - Replace chat package with conversation flow architecture - Add channel identity avatar support (migration 0002) - Refactor channel adapters, identities, and message routing - Update frontend: simplify composables, modernize UI components - Improve Docker builds with cache mounts and version metadata - Optimize healthchecks and simplify service dependencies --- .gitignore | 3 +- DEPLOYMENT.md | 4 +- agent/bun.lock | 363 +++++ agent/package.json | 4 + agent/src/model.ts | 24 +- agent/src/models.ts | 5 +- agent/src/types/model.ts | 8 +- cmd/agent/main.go | 7 +- db/migrations/0001_init.up.sql | 2 +- .../0002_channel_identity_avatar.down.sql | 2 + .../0002_channel_identity_avatar.up.sql | 3 + db/queries/channel_identities.sql | 25 +- db/queries/messages.sql | 136 +- deploy.sh | 6 + docker-compose.yml | 31 +- docker/Dockerfile.mcp | 8 +- docker/Dockerfile.server | 18 +- docker/Dockerfile.web | 4 +- go.mod | 60 +- go.sum | 144 +- ...go => service_consume_integration_test.go} | 98 +- internal/channel/adapter.go | 6 + internal/channel/adapters/feishu/feishu.go | 57 +- .../feishu/feishu_integration_test.go | 29 + .../channel/adapters/feishu/feishu_test.go | 110 +- internal/channel/adapters/feishu/inbound.go | 64 +- internal/channel/identities/service.go | 22 +- .../service_identity_integration_test.go | 6 +- .../identities/service_integration_test.go | 6 +- internal/channel/identities/types.go | 1 + internal/channel/registry.go | 14 + internal/channel/service.go | 12 +- internal/chat/assistant_output.go | 36 - internal/chat/resolver.go | 1175 ----------------- internal/chat/resolver_memory_context_test.go | 55 - internal/chat/resolver_test.go | 158 --- internal/chat/schedule_gateway.go | 26 - internal/chat/service.go | 1000 -------------- .../chat/service_presence_integration_test.go | 244 ---- internal/chat/types.go | 269 ---- internal/conversation/flow/resolver.go | 32 +- internal/conversation/resolver.go | 12 +- .../service_presence_integration_test.go | 1 + internal/db/sqlc/channel_identities.sql.go | 37 +- internal/db/sqlc/messages.sql.go | 152 ++- internal/db/sqlc/models.go | 1 + internal/handlers/message.go | 2 +- internal/handlers/models.go | 2 +- internal/handlers/providers.go | 4 +- internal/message/service.go | 14 + internal/message/types.go | 2 + internal/models/models.go | 10 +- internal/models/models_test.go | 8 +- internal/models/types.go | 18 +- internal/providers/service.go | 4 +- internal/providers/types.go | 5 + internal/router/identity.go | 51 +- internal/router/identity_test.go | 53 +- package.json | 6 +- packages/cli/src/cli/index.ts | 2 +- packages/sdk/src/types.gen.ts | 9 +- packages/shared/src/chatInfo.ts | 9 +- packages/shared/src/model.ts | 14 +- packages/web/package.json | 1 - packages/web/src/components/Sidebar/index.vue | 190 +-- .../Sidebar/lists/chat-list-menu.vue | 114 -- .../Sidebar/lists/settings-list-menu.vue | 87 -- .../web/src/components/Sidebar/lists/types.ts | 4 - .../web/src/components/add-provider/index.vue | 18 +- .../chat-list/assistant-chat/index.vue | 96 +- .../web/src/components/chat-list/index.vue | 116 +- .../components/chat-list/robot-chat/index.vue | 71 - .../components/chat-list/user-chat/index.vue | 96 +- .../web/src/components/create-mcp/index.vue | 2 +- .../web/src/components/create-model/index.vue | 37 +- .../src/components/main-container/index.vue | 4 - packages/web/src/composables/api/useAuth.ts | 13 - .../web/src/composables/api/useBotSettings.ts | 42 - packages/web/src/composables/api/useBots.ts | 196 --- .../web/src/composables/api/useChannels.ts | 88 -- packages/web/src/composables/api/useChat.ts | 527 +++----- packages/web/src/composables/api/useMcp.ts | 42 +- packages/web/src/composables/api/useModels.ts | 87 -- .../web/src/composables/api/usePlatform.ts | 16 +- .../web/src/composables/api/useProviders.ts | 80 -- packages/web/src/composables/api/useUsers.ts | 60 - packages/web/src/i18n/locales/en.json | 16 +- packages/web/src/i18n/locales/zh.json | 18 +- packages/web/src/main.ts | 7 +- .../src/pages/bots/components/bot-card.vue | 4 +- .../pages/bots/components/bot-channels.vue | 74 +- .../pages/bots/components/bot-settings.vue | 132 +- .../components/channel-settings-panel.vue | 61 +- .../src/pages/bots/components/create-bot.vue | 19 +- .../pages/bots/components/model-select.vue | 12 +- packages/web/src/pages/bots/detail.vue | 114 +- packages/web/src/pages/bots/index.vue | 23 +- .../src/pages/chat/components/bot-sidebar.vue | 28 +- .../src/pages/chat/components/chat-area.vue | 99 +- .../pages/chat/components/message-item.vue | 150 ++- .../pages/chat/components/tool-call-block.vue | 8 +- packages/web/src/pages/chat/index.vue | 162 +-- packages/web/src/pages/login/index.vue | 16 +- packages/web/src/pages/main-section/index.vue | 4 - packages/web/src/pages/mcp/index.vue | 2 +- .../pages/models/components/model-item.vue | 6 +- .../pages/models/components/model-list.vue | 6 +- .../pages/models/components/provider-form.vue | 4 +- packages/web/src/pages/models/index.vue | 76 +- .../web/src/pages/models/model-setting.vue | 69 +- packages/web/src/pages/settings/index.vue | 157 ++- packages/web/src/pages/settings/user.vue | 68 +- packages/web/src/router.ts | 169 +-- packages/web/src/store/chat-list.ts | 878 ++++++------ packages/web/src/types/index.ts | 0 packages/web/src/utils/request.ts | 77 -- pnpm-lock.yaml | 238 +++- scripts/containerd-install.sh | 31 +- spec/docs.go | 23 +- spec/swagger.json | 23 +- spec/swagger.yaml | 21 +- 121 files changed, 3514 insertions(+), 5961 deletions(-) create mode 100644 db/migrations/0002_channel_identity_avatar.down.sql create mode 100644 db/migrations/0002_channel_identity_avatar.up.sql rename internal/bind/{service_integration_test.go => service_consume_integration_test.go} (56%) delete mode 100644 internal/chat/assistant_output.go delete mode 100644 internal/chat/resolver.go delete mode 100644 internal/chat/resolver_memory_context_test.go delete mode 100644 internal/chat/resolver_test.go delete mode 100644 internal/chat/schedule_gateway.go delete mode 100644 internal/chat/service.go delete mode 100644 internal/chat/service_presence_integration_test.go delete mode 100644 internal/chat/types.go delete mode 100644 packages/web/src/components/Sidebar/lists/chat-list-menu.vue delete mode 100644 packages/web/src/components/Sidebar/lists/settings-list-menu.vue delete mode 100644 packages/web/src/components/Sidebar/lists/types.ts delete mode 100644 packages/web/src/components/chat-list/robot-chat/index.vue delete mode 100644 packages/web/src/composables/api/useAuth.ts delete mode 100644 packages/web/src/composables/api/useBotSettings.ts delete mode 100644 packages/web/src/composables/api/useBots.ts delete mode 100644 packages/web/src/composables/api/useChannels.ts delete mode 100644 packages/web/src/composables/api/useModels.ts delete mode 100644 packages/web/src/composables/api/useProviders.ts delete mode 100644 packages/web/src/composables/api/useUsers.ts delete mode 100644 packages/web/src/types/index.ts delete mode 100644 packages/web/src/utils/request.ts diff --git a/.gitignore b/.gitignore index c68c9405..33664c70 100644 --- a/.gitignore +++ b/.gitignore @@ -98,4 +98,5 @@ memory.db config.toml .workdocs/ -data \ No newline at end of file +data +_main-ref/ diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index b53393b0..ef862655 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -20,7 +20,7 @@ Default credentials: `admin` / `admin123` ```bash cp docker/config/config.docker.toml config.toml nano config.toml # Change passwords and secrets -nerdctl build -f docker/Dockerfile.mcp -t memoh-mcp:latest . +nerdctl build -f docker/Dockerfile.mcp -t docker.io/library/memoh-mcp:latest . docker compose up -d ``` @@ -37,7 +37,7 @@ Must change in `config.toml`: docker compose up -d # Start docker compose down # Stop docker compose logs -f # View logs -nerdctl ps -a | grep memoh-bot # View bot containers +nerdctl images # Ensure that memoh-mcp:latest exsits ``` ## Production diff --git a/agent/bun.lock b/agent/bun.lock index ff9b77a5..9fd15e7e 100644 --- a/agent/bun.lock +++ b/agent/bun.lock @@ -5,7 +5,26 @@ "": { "name": "agent", "dependencies": { + "@ai-sdk/amazon-bedrock": "^4.0.56", + "@ai-sdk/anthropic": "^3.0.9", + "@ai-sdk/azure": "^3.0.28", + "@ai-sdk/google": "^3.0.6", + "@ai-sdk/mcp": "^1.0.6", + "@ai-sdk/mistral": "^3.0.19", + "@ai-sdk/openai": "^3.0.7", + "@ai-sdk/xai": "^3.0.54", + "@elysiajs/bearer": "^1.4.2", + "@elysiajs/cors": "^1.4.1", + "@modelcontextprotocol/sdk": "^1.25.2", + "@mozilla/readability": "^0.6.0", + "@types/jsdom": "^27.0.0", + "@types/turndown": "^5.0.6", + "ai": "^6.0.25", "elysia": "latest", + "jsdom": "^27.4.0", + "toml": "^3.0.0", + "turndown": "^7.2.2", + "zod": "^4.3.5", }, "devDependencies": { "bun-types": "latest", @@ -13,44 +32,388 @@ }, }, "packages": { + "@acemir/cssom": ["@acemir/cssom@0.9.31", "", {}, "sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA=="], + + "@ai-sdk/amazon-bedrock": ["@ai-sdk/amazon-bedrock@4.0.56", "", { "dependencies": { "@ai-sdk/anthropic": "3.0.42", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.14", "@smithy/eventstream-codec": "^4.0.1", "@smithy/util-utf8": "^4.0.0", "aws4fetch": "^1.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-LOJud09s2zUFYsMGOHv55m3ERxDrZ6+1PpcsihWUPloA4DcXJZIVRABck9OCU5NvUWR75jxsymg/+p79ox6IOw=="], + + "@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.42", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.14" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-snoLXB9DmvAmmngbPN/Io8IGzZ9zWpC208EgIIztYf1e1JhwuMkgKCYkL30vGhSen4PrBafu2+sO4G/17wu45A=="], + + "@ai-sdk/azure": ["@ai-sdk/azure@3.0.28", "", { "dependencies": { "@ai-sdk/openai": "3.0.27", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.14" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-FDzx/MF7M9boJ7pB5zMUwbGU2HadYM0ZsI7b/sPiHKecwirk7Endl9RtwGrfjauPDrRnP0W+djMc2NhKwp0B8w=="], + + "@ai-sdk/gateway": ["@ai-sdk/gateway@3.0.42", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.14", "@vercel/oidc": "3.1.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Il9lZWPUQMX59H5yJvA08gxfL2Py8oHwvAYRnK0Mt91S+JgPcyk/yEmXNDZG9ghJrwSawtK5Yocy8OnzsTOGsw=="], + + "@ai-sdk/google": ["@ai-sdk/google@3.0.26", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.14" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-u4yOLP1nDbrGbfpWiwCFLTPVDq3ak0qNgSnf3HB+j2NpJoJCX9gApzyYnYm2CRB8IDiyaeT6Xcjv9IIOv1mTYQ=="], + + "@ai-sdk/mcp": ["@ai-sdk/mcp@1.0.20", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.14", "pkce-challenge": "^5.0.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-wrPYSPY2oigua7QlW6ou3ZzggV7Xpf8sJJKWGbiXSjHz9ycZaewP3lB4QnFlp7hyJFj2+9mKqxis9ibk9ODeoQ=="], + + "@ai-sdk/mistral": ["@ai-sdk/mistral@3.0.19", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.14" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-yd0OJ3fm2YKdwxh1pd9m720sENVVcylAD+Bki8C80QqVpUxGNL1/C4N4JJGb56eCCWr6VU/3gHFe9PKui9n/Hg=="], + + "@ai-sdk/openai": ["@ai-sdk/openai@3.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.14" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-pLMxWOypwroXiK9dxNpn60/HGhWWWDEOJ3lo9vZLoxvpJNtKnLKojwVIvlW3yEjlD7ll1+jUO2uzsABNTaP5Yg=="], + + "@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.29", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.14" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-yoZ+jxBzVA7cQIrcOn2ZXAVeqsNdhqFGWW3VwTJwNnmeOLCACoz6+pu58LY/zjxEJGVrl5X+JazdY5LhDMey8A=="], + + "@ai-sdk/provider": ["@ai-sdk/provider@3.0.8", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ=="], + + "@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.14", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-7bzKd9lgiDeXM7O4U4nQ8iTxguAOkg8LZGD9AfDVZYjO5cKYRwBPwVjboFcVrxncRHu0tYxZtXZtiLKpG4pEng=="], + + "@ai-sdk/xai": ["@ai-sdk/xai@3.0.54", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.29", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.14" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-zWKj5v3cmJAc5aZ4iY1jB2rmqMhXVLdMHAk76srSByTmPnssGaW9XNvIgyCAD2WD1g14P3YjRg1FTK+K2Z2Bjw=="], + + "@asamuzakjp/css-color": ["@asamuzakjp/css-color@4.1.2", "", { "dependencies": { "@csstools/css-calc": "^3.0.0", "@csstools/css-color-parser": "^4.0.1", "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0", "lru-cache": "^11.2.5" } }, "sha512-NfBUvBaYgKIuq6E/RBLY1m0IohzNHAYyaJGuTK79Z23uNwmz2jl1mPsC5ZxCCxylinKhT1Amn5oNTlx1wN8cQg=="], + + "@asamuzakjp/dom-selector": ["@asamuzakjp/dom-selector@6.7.8", "", { "dependencies": { "@asamuzakjp/nwsapi": "^2.3.9", "bidi-js": "^1.0.3", "css-tree": "^3.1.0", "is-potential-custom-element-name": "^1.0.1", "lru-cache": "^11.2.5" } }, "sha512-stisC1nULNc9oH5lakAj8MH88ZxeGxzyWNDfbdCxvJSJIvDsHNZqYvscGTgy/ysgXWLJPt6K/4t0/GjvtKcFJQ=="], + + "@asamuzakjp/nwsapi": ["@asamuzakjp/nwsapi@2.3.9", "", {}, "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q=="], + + "@aws-crypto/crc32": ["@aws-crypto/crc32@5.2.0", "", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg=="], + + "@aws-crypto/util": ["@aws-crypto/util@5.2.0", "", { "dependencies": { "@aws-sdk/types": "^3.222.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ=="], + + "@aws-sdk/types": ["@aws-sdk/types@3.973.1", "", { "dependencies": { "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-DwHBiMNOB468JiX6+i34c+THsKHErYUdNQ3HexeXZvVn4zouLjgaS4FejiGSi2HyBuzuyHg7SuOPmjSvoU9NRg=="], + "@borewit/text-codec": ["@borewit/text-codec@0.2.1", "https://registry.npmmirror.com/@borewit/text-codec/-/text-codec-0.2.1.tgz", {}, "sha512-k7vvKPbf7J2fZ5klGRD9AeKfUvojuZIQ3BT5u7Jfv+puwXkUBUT5PVyMDfJZpy30CBDXGMgw7fguK/lpOMBvgw=="], + "@csstools/color-helpers": ["@csstools/color-helpers@6.0.1", "", {}, "sha512-NmXRccUJMk2AWA5A7e5a//3bCIMyOu2hAtdRYrhPPHjDxINuCwX1w6rnIZ4xjLcp0ayv6h8Pc3X0eJUGiAAXHQ=="], + + "@csstools/css-calc": ["@csstools/css-calc@3.0.1", "", { "peerDependencies": { "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-bsDKIP6f4ta2DO9t+rAbSSwv4EMESXy5ZIvzQl1afmD6Z1XHkVu9ijcG9QR/qSgQS1dVa+RaQ/MfQ7FIB/Dn1Q=="], + + "@csstools/css-color-parser": ["@csstools/css-color-parser@4.0.1", "", { "dependencies": { "@csstools/color-helpers": "^6.0.1", "@csstools/css-calc": "^3.0.0" }, "peerDependencies": { "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-vYwO15eRBEkeF6xjAno/KQ61HacNhfQuuU/eGwH67DplL0zD5ZixUa563phQvUelA07yDczIXdtmYojCphKJcw=="], + + "@csstools/css-parser-algorithms": ["@csstools/css-parser-algorithms@4.0.0", "", { "peerDependencies": { "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w=="], + + "@csstools/css-syntax-patches-for-csstree": ["@csstools/css-syntax-patches-for-csstree@1.0.27", "", {}, "sha512-sxP33Jwg1bviSUXAV43cVYdmjt2TLnLXNqCWl9xmxHawWVjGz/kEbdkr7F9pxJNBN2Mh+dq0crgItbW6tQvyow=="], + + "@csstools/css-tokenizer": ["@csstools/css-tokenizer@4.0.0", "", {}, "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA=="], + + "@elysiajs/bearer": ["@elysiajs/bearer@1.4.3", "", { "peerDependencies": { "elysia": ">= 1.4.3" } }, "sha512-UWJ94jGGOzSlD3CCspC11/vFGKwy6RI9QvaZVPzlSu1Wxp/pKmOhKA+R2ppfbluMHXfxcc2xgK3x4+uuCML7GA=="], + + "@elysiajs/cors": ["@elysiajs/cors@1.4.1", "", { "peerDependencies": { "elysia": ">= 1.4.0" } }, "sha512-lQfad+F3r4mNwsxRKbXyJB8Jg43oAOXjRwn7sKUL6bcOW3KjUqUimTS+woNpO97efpzjtDE0tEjGk9DTw8lqTQ=="], + + "@exodus/bytes": ["@exodus/bytes@1.14.0", "", { "peerDependencies": { "@noble/hashes": "^1.8.0 || ^2.0.0" }, "optionalPeers": ["@noble/hashes"] }, "sha512-YiY1OmY6Qhkvmly8vZiD8wZRpW/npGZNg+0Sk8mstxirRHCg6lolHt5tSODCfuNPE/fBsAqRwDJE417x7jDDHA=="], + + "@hono/node-server": ["@hono/node-server@1.19.9", "", { "peerDependencies": { "hono": "^4" } }, "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw=="], + + "@mixmark-io/domino": ["@mixmark-io/domino@2.2.0", "", {}, "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw=="], + + "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.26.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-Y5RmPncpiDtTXDbLKswIJzTqu2hyBKxTNsgKqKclDbhIgg1wgtf1fRuvxgTnRfcnxtvvgbIEcqUOzZrJ6iSReg=="], + + "@mozilla/readability": ["@mozilla/readability@0.6.0", "", {}, "sha512-juG5VWh4qAivzTAeMzvY9xs9HY5rAcr2E4I7tiSSCokRFi7XIZCAu92ZkSTsIj1OPceCifL3cpfteP3pDT9/QQ=="], + + "@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="], + "@sinclair/typebox": ["@sinclair/typebox@0.34.48", "https://registry.npmmirror.com/@sinclair/typebox/-/typebox-0.34.48.tgz", {}, "sha512-kKJTNuK3AQOrgjjotVxMrCn1sUJwM76wMszfq1kdU4uYVJjvEWuFQ6HgvLt4Xz3fSmZlTOxJ/Ie13KnIcWQXFA=="], + "@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.2.8", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.12.0", "@smithy/util-hex-encoding": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-jS/O5Q14UsufqoGhov7dHLOPCzkYJl9QDzusI2Psh4wyYx/izhzvX9P4D69aTxcdfVhEPhjK+wYyn/PzLjKbbw=="], + + "@smithy/is-array-buffer": ["@smithy/is-array-buffer@4.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-DZZZBvC7sjcYh4MazJSGiWMI2L7E0oCiRHREDzIxi/M2LY79/21iXt6aPLHge82wi5LsuRF5A06Ds3+0mlh6CQ=="], + + "@smithy/types": ["@smithy/types@4.12.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-9YcuJVTOBDjg9LWo23Qp0lTQ3D7fQsQtwle0jVfpbUHy9qBwCEgKuVH4FqFB3VYu0nwdHKiEMA+oXz7oV8X1kw=="], + + "@smithy/util-buffer-from": ["@smithy/util-buffer-from@4.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-kAY9hTKulTNevM2nlRtxAG2FQ3B2OR6QIrPY3zE5LqJy1oxzmgBGsHLWTcNhWXKchgA0WHW+mZkQrng/pgcCew=="], + + "@smithy/util-hex-encoding": ["@smithy/util-hex-encoding@4.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-CCQBwJIvXMLKxVbO88IukazJD9a4kQ9ZN7/UMGBjBcJYvatpWk+9g870El4cB8/EJxfe+k+y0GmR9CAzkF+Nbw=="], + + "@smithy/util-utf8": ["@smithy/util-utf8@4.2.0", "", { "dependencies": { "@smithy/util-buffer-from": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-zBPfuzoI8xyBtR2P6WQj63Rz8i3AmfAaJLuNG8dWsfvPe8lO4aCPYLn879mEgHndZH1zQ2oXmG8O1GGzzaoZiw=="], + + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + "@tokenizer/inflate": ["@tokenizer/inflate@0.4.1", "https://registry.npmmirror.com/@tokenizer/inflate/-/inflate-0.4.1.tgz", { "dependencies": { "debug": "^4.4.3", "token-types": "^6.1.1" } }, "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA=="], "@tokenizer/token": ["@tokenizer/token@0.3.0", "https://registry.npmmirror.com/@tokenizer/token/-/token-0.3.0.tgz", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="], + "@types/jsdom": ["@types/jsdom@27.0.0", "", { "dependencies": { "@types/node": "*", "@types/tough-cookie": "*", "parse5": "^7.0.0" } }, "sha512-NZyFl/PViwKzdEkQg96gtnB8wm+1ljhdDay9ahn4hgb+SfVtPCbm3TlmDUFXTA+MGN3CijicnMhG18SI5H3rFw=="], + "@types/node": ["@types/node@25.0.10", "https://registry.npmmirror.com/@types/node/-/node-25.0.10.tgz", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-zWW5KPngR/yvakJgGOmZ5vTBemDoSqF3AcV/LrO5u5wTWyEAVVh+IT39G4gtyAkh3CtTZs8aX/yRM82OfzHJRg=="], + "@types/tough-cookie": ["@types/tough-cookie@4.0.5", "", {}, "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA=="], + + "@types/turndown": ["@types/turndown@5.0.6", "", {}, "sha512-ru00MoyeeouE5BX4gRL+6m/BsDfbRayOskWqUvh7CLGW+UXxHQItqALa38kKnOiZPqJrtzJUgAC2+F0rL1S4Pg=="], + + "@vercel/oidc": ["@vercel/oidc@3.1.0", "", {}, "sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w=="], + + "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], + + "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], + + "ai": ["ai@6.0.82", "", { "dependencies": { "@ai-sdk/gateway": "3.0.42", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.14", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-WLml1ab2IXtREgkxrq2Pl6lFO6NKgC17MqTzmK5mO1UO6tMAJiVjkednw9p0j4+/LaUIZQoRiIT8wA37LswZ9Q=="], + + "ajv": ["ajv@8.17.1", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="], + + "ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], + + "aws4fetch": ["aws4fetch@1.0.20", "", {}, "sha512-/djoAN709iY65ETD6LKCtyyEI04XIBP5xVvfmNxsEP0uJB5tyaGBztSryRr4HqMStr9R06PisQE7m9zDTXKu6g=="], + + "bidi-js": ["bidi-js@1.0.3", "", { "dependencies": { "require-from-string": "^2.0.2" } }, "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw=="], + + "body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="], + "bun-types": ["bun-types@1.3.7", "https://registry.npmmirror.com/bun-types/-/bun-types-1.3.7.tgz", { "dependencies": { "@types/node": "*" } }, "sha512-qyschsA03Qz+gou+apt6HNl6HnI+sJJLL4wLDke4iugsE6584CMupOtTY1n+2YC9nGVrEKUlTs99jjRLKgWnjQ=="], + "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], + + "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], + + "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], + + "content-disposition": ["content-disposition@1.0.1", "", {}, "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q=="], + + "content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="], + "cookie": ["cookie@1.1.1", "https://registry.npmmirror.com/cookie/-/cookie-1.1.1.tgz", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], + "cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="], + + "cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="], + + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + + "css-tree": ["css-tree@3.1.0", "", { "dependencies": { "mdn-data": "2.12.2", "source-map-js": "^1.0.1" } }, "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w=="], + + "cssstyle": ["cssstyle@5.3.7", "", { "dependencies": { "@asamuzakjp/css-color": "^4.1.1", "@csstools/css-syntax-patches-for-csstree": "^1.0.21", "css-tree": "^3.1.0", "lru-cache": "^11.2.4" } }, "sha512-7D2EPVltRrsTkhpQmksIu+LxeWAIEk6wRDMJ1qljlv+CKHJM+cJLlfhWIzNA44eAsHXSNe3+vO6DW1yCYx8SuQ=="], + + "data-urls": ["data-urls@6.0.1", "", { "dependencies": { "whatwg-mimetype": "^5.0.0", "whatwg-url": "^15.1.0" } }, "sha512-euIQENZg6x8mj3fO6o9+fOW8MimUI4PpD/fZBhJfeioZVy9TUpM4UY7KjQNVZFlqwJ0UdzRDzkycB997HEq1BQ=="], + "debug": ["debug@4.4.3", "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + "decimal.js": ["decimal.js@10.6.0", "", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="], + + "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], + + "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], + + "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], + "elysia": ["elysia@1.4.22", "https://registry.npmmirror.com/elysia/-/elysia-1.4.22.tgz", { "dependencies": { "cookie": "^1.1.1", "exact-mirror": "^0.2.6", "fast-decode-uri-component": "^1.0.1", "memoirist": "^0.4.0" }, "peerDependencies": { "@sinclair/typebox": ">= 0.34.0 < 1", "@types/bun": ">= 1.2.0", "file-type": ">= 20.0.0", "openapi-types": ">= 12.0.0", "typescript": ">= 5.0.0" }, "optionalPeers": ["@types/bun", "typescript"] }, "sha512-Q90VCb1RVFxnFaRV0FDoSylESQQLWgLHFmWciQJdX9h3b2cSasji9KWEUvaJuy/L9ciAGg4RAhUVfsXHg5K2RQ=="], + "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], + + "entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], + + "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], + + "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], + + "es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="], + + "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], + + "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="], + + "eventsource": ["eventsource@3.0.7", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="], + + "eventsource-parser": ["eventsource-parser@3.0.6", "", {}, "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg=="], + "exact-mirror": ["exact-mirror@0.2.6", "https://registry.npmmirror.com/exact-mirror/-/exact-mirror-0.2.6.tgz", { "peerDependencies": { "@sinclair/typebox": "^0.34.15" }, "optionalPeers": ["@sinclair/typebox"] }, "sha512-7s059UIx9/tnOKSySzUk5cPGkoILhTE4p6ncf6uIPaQ+9aRBQzQjc9+q85l51+oZ+P6aBxh084pD0CzBQPcFUA=="], + "express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="], + + "express-rate-limit": ["express-rate-limit@8.2.1", "", { "dependencies": { "ip-address": "10.0.1" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g=="], + "fast-decode-uri-component": ["fast-decode-uri-component@1.0.1", "https://registry.npmmirror.com/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz", {}, "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg=="], + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], + + "fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="], + "file-type": ["file-type@21.3.0", "https://registry.npmmirror.com/file-type/-/file-type-21.3.0.tgz", { "dependencies": { "@tokenizer/inflate": "^0.4.1", "strtok3": "^10.3.4", "token-types": "^6.1.1", "uint8array-extras": "^1.4.0" } }, "sha512-8kPJMIGz1Yt/aPEwOsrR97ZyZaD1Iqm8PClb1nYFclUCkBi0Ma5IsYNQzvSFS9ib51lWyIw5mIT9rWzI/xjpzA=="], + "finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="], + + "forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="], + + "fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="], + + "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], + + "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], + + "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], + + "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], + + "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], + + "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], + + "hono": ["hono@4.11.9", "", {}, "sha512-Eaw2YTGM6WOxA6CXbckaEvslr2Ne4NFsKrvc0v97JD5awbmeBLO5w9Ho9L9kmKonrwF9RJlW6BxT1PVv/agBHQ=="], + + "html-encoding-sniffer": ["html-encoding-sniffer@6.0.0", "", { "dependencies": { "@exodus/bytes": "^1.6.0" } }, "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg=="], + + "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], + + "http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="], + + "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], + + "iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], + "ieee754": ["ieee754@1.2.1", "https://registry.npmmirror.com/ieee754/-/ieee754-1.2.1.tgz", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + + "ip-address": ["ip-address@10.0.1", "", {}, "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA=="], + + "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], + + "is-potential-custom-element-name": ["is-potential-custom-element-name@1.0.1", "", {}, "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ=="], + + "is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="], + + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + + "jose": ["jose@6.1.3", "", {}, "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ=="], + + "jsdom": ["jsdom@27.4.0", "", { "dependencies": { "@acemir/cssom": "^0.9.28", "@asamuzakjp/dom-selector": "^6.7.6", "@exodus/bytes": "^1.6.0", "cssstyle": "^5.3.4", "data-urls": "^6.0.0", "decimal.js": "^10.6.0", "html-encoding-sniffer": "^6.0.0", "http-proxy-agent": "^7.0.2", "https-proxy-agent": "^7.0.6", "is-potential-custom-element-name": "^1.0.1", "parse5": "^8.0.0", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", "tough-cookie": "^6.0.0", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^8.0.0", "whatwg-mimetype": "^4.0.0", "whatwg-url": "^15.1.0", "ws": "^8.18.3", "xml-name-validator": "^5.0.0" }, "peerDependencies": { "canvas": "^3.0.0" }, "optionalPeers": ["canvas"] }, "sha512-mjzqwWRD9Y1J1KUi7W97Gja1bwOOM5Ug0EZ6UDK3xS7j7mndrkwozHtSblfomlzyB4NepioNt+B2sOSzczVgtQ=="], + + "json-schema": ["json-schema@0.4.0", "", {}, "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="], + + "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + + "json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="], + + "lru-cache": ["lru-cache@11.2.6", "", {}, "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ=="], + + "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], + + "mdn-data": ["mdn-data@2.12.2", "", {}, "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA=="], + + "media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="], + "memoirist": ["memoirist@0.4.0", "https://registry.npmmirror.com/memoirist/-/memoirist-0.4.0.tgz", {}, "sha512-zxTgA0mSYELa66DimuNQDvyLq36AwDlTuVRbnQtB+VuTcKWm5Qc4z3WkSpgsFWHNhexqkIooqpv4hdcqrX5Nmg=="], + "merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="], + + "mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], + + "mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], + "ms": ["ms@2.1.3", "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + "negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], + + "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], + + "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], + + "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="], + + "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], + "openapi-types": ["openapi-types@12.1.3", "https://registry.npmmirror.com/openapi-types/-/openapi-types-12.1.3.tgz", {}, "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw=="], + "parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="], + + "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], + + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + + "path-to-regexp": ["path-to-regexp@8.3.0", "", {}, "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA=="], + + "pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="], + + "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="], + + "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], + + "qs": ["qs@6.14.2", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q=="], + + "range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="], + + "raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="], + + "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], + + "router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="], + + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + + "saxes": ["saxes@6.0.0", "", { "dependencies": { "xmlchars": "^2.2.0" } }, "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA=="], + + "send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="], + + "serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="], + + "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], + + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], + + "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + + "side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="], + + "side-channel-list": ["side-channel-list@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" } }, "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA=="], + + "side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="], + + "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], + + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + + "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], + "strtok3": ["strtok3@10.3.4", "https://registry.npmmirror.com/strtok3/-/strtok3-10.3.4.tgz", { "dependencies": { "@tokenizer/token": "^0.3.0" } }, "sha512-KIy5nylvC5le1OdaaoCJ07L+8iQzJHGH6pWDuzS+d07Cu7n1MZ2x26P8ZKIWfbK02+XIL8Mp4RkWeqdUCrDMfg=="], + "symbol-tree": ["symbol-tree@3.2.4", "", {}, "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="], + + "tldts": ["tldts@7.0.23", "", { "dependencies": { "tldts-core": "^7.0.23" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-ASdhgQIBSay0R/eXggAkQ53G4nTJqTXqC2kbaBbdDwM7SkjyZyO0OaaN1/FH7U/yCeqOHDwFO5j8+Os/IS1dXw=="], + + "tldts-core": ["tldts-core@7.0.23", "", {}, "sha512-0g9vrtDQLrNIiCj22HSe9d4mLVG3g5ph5DZ8zCKBr4OtrspmNB6ss7hVyzArAeE88ceZocIEGkyW1Ime7fxPtQ=="], + + "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], + "token-types": ["token-types@6.1.2", "https://registry.npmmirror.com/token-types/-/token-types-6.1.2.tgz", { "dependencies": { "@borewit/text-codec": "^0.2.1", "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww=="], + "toml": ["toml@3.0.0", "", {}, "sha512-y/mWCZinnvxjTKYhJ+pYxwD0mRLVvOtdS2Awbgxln6iEnt4rk0yBxeSBHkGJcPucRiG0e55mwWp+g/05rsrd6w=="], + + "tough-cookie": ["tough-cookie@6.0.0", "", { "dependencies": { "tldts": "^7.0.5" } }, "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w=="], + + "tr46": ["tr46@6.0.0", "", { "dependencies": { "punycode": "^2.3.1" } }, "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw=="], + + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "turndown": ["turndown@7.2.2", "", { "dependencies": { "@mixmark-io/domino": "^2.2.0" } }, "sha512-1F7db8BiExOKxjSMU2b7if62D/XOyQyZbPKq/nUwopfgnHlqXHqQ0lvfUTeUIr1lZJzOPFn43dODyMSIfvWRKQ=="], + + "type-is": ["type-is@2.0.1", "", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="], + "uint8array-extras": ["uint8array-extras@1.5.0", "https://registry.npmmirror.com/uint8array-extras/-/uint8array-extras-1.5.0.tgz", {}, "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A=="], "undici-types": ["undici-types@7.16.0", "https://registry.npmmirror.com/undici-types/-/undici-types-7.16.0.tgz", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + + "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], + + "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], + + "w3c-xmlserializer": ["w3c-xmlserializer@5.0.0", "", { "dependencies": { "xml-name-validator": "^5.0.0" } }, "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA=="], + + "webidl-conversions": ["webidl-conversions@8.0.1", "", {}, "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ=="], + + "whatwg-mimetype": ["whatwg-mimetype@4.0.0", "", {}, "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="], + + "whatwg-url": ["whatwg-url@15.1.0", "", { "dependencies": { "tr46": "^6.0.0", "webidl-conversions": "^8.0.0" } }, "sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g=="], + + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + + "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], + + "ws": ["ws@8.19.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg=="], + + "xml-name-validator": ["xml-name-validator@5.0.0", "", {}, "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg=="], + + "xmlchars": ["xmlchars@2.2.0", "", {}, "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw=="], + + "zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], + + "zod-to-json-schema": ["zod-to-json-schema@3.25.1", "", { "peerDependencies": { "zod": "^3.25 || ^4" } }, "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA=="], + + "@aws-crypto/util/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="], + + "data-urls/whatwg-mimetype": ["whatwg-mimetype@5.0.0", "", {}, "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw=="], + + "express/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], + + "jsdom/parse5": ["parse5@8.0.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA=="], + + "@aws-crypto/util/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="], + + "@aws-crypto/util/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="], } } diff --git a/agent/package.json b/agent/package.json index b6de43c7..24b7a82a 100644 --- a/agent/package.json +++ b/agent/package.json @@ -7,10 +7,14 @@ "start": "pnpm run build && bun run dist/index.js" }, "dependencies": { + "@ai-sdk/amazon-bedrock": "^4.0.56", "@ai-sdk/anthropic": "^3.0.9", + "@ai-sdk/azure": "^3.0.28", "@ai-sdk/google": "^3.0.6", "@ai-sdk/mcp": "^1.0.6", + "@ai-sdk/mistral": "^3.0.19", "@ai-sdk/openai": "^3.0.7", + "@ai-sdk/xai": "^3.0.54", "@elysiajs/bearer": "^1.4.2", "@elysiajs/cors": "^1.4.1", "@modelcontextprotocol/sdk": "^1.25.2", diff --git a/agent/src/model.ts b/agent/src/model.ts index 38ebf601..08003810 100644 --- a/agent/src/model.ts +++ b/agent/src/model.ts @@ -2,6 +2,10 @@ import { createGateway as createAiGateway } from 'ai' import { createOpenAI } from '@ai-sdk/openai' import { createAnthropic } from '@ai-sdk/anthropic' import { createGoogleGenerativeAI } from '@ai-sdk/google' +import { createAzure } from '@ai-sdk/azure' +import { createAmazonBedrock } from '@ai-sdk/amazon-bedrock' +import { createMistral } from '@ai-sdk/mistral' +import { createXai } from '@ai-sdk/xai' import { ClientType, ModelConfig } from './types' export const createModel = (model: ModelConfig) => { @@ -11,15 +15,31 @@ export const createModel = (model: ModelConfig) => { switch (model.clientType) { case ClientType.OpenAI: - case ClientType.OpenAICompatible: { + case ClientType.OpenAICompat: + case ClientType.Ollama: + case ClientType.Dashscope: { + // All OpenAI-compatible providers use .chat() for /chat/completions const provider = createOpenAI({ apiKey, baseURL }) - // Use .chat() to call /chat/completions (not /responses which only OpenAI supports) return provider.chat(modelId) } case ClientType.Anthropic: return createAnthropic({ apiKey, baseURL })(modelId) case ClientType.Google: return createGoogleGenerativeAI({ apiKey, baseURL })(modelId) + case ClientType.Azure: + return createAzure({ apiKey, baseURL })(modelId) + case ClientType.Bedrock: { + // Bedrock uses AWS credentials; apiKey as accessKeyId, metadata for secretAccessKey + // Falls back to AWS default credential chain if not provided + const opts: Record = {} + if (baseURL) opts.region = baseURL + if (apiKey) opts.accessKeyId = apiKey + return createAmazonBedrock(opts)(modelId) + } + case ClientType.Mistral: + return createMistral({ apiKey, baseURL: baseURL || undefined })(modelId) + case ClientType.XAI: + return createXai({ apiKey, baseURL: baseURL || undefined })(modelId) default: return createAiGateway({ apiKey, baseURL })(modelId) } diff --git a/agent/src/models.ts b/agent/src/models.ts index d7403c97..c2a39c36 100644 --- a/agent/src/models.ts +++ b/agent/src/models.ts @@ -8,7 +8,10 @@ export const AgentSkillModel = z.object({ metadata: z.record(z.string(), z.any()).optional(), }) -export const ClientTypeModel = z.enum(['openai', 'openai-compatible', 'anthropic', 'google']) +export const ClientTypeModel = z.enum([ + 'openai', 'openai-compat', 'anthropic', 'google', + 'azure', 'bedrock', 'mistral', 'xai', 'ollama', 'dashscope', +]) export const ModelConfigModel = z.object({ modelId: z.string().min(1, 'Model ID is required'), diff --git a/agent/src/types/model.ts b/agent/src/types/model.ts index d0b21f8a..7de45ac6 100644 --- a/agent/src/types/model.ts +++ b/agent/src/types/model.ts @@ -1,8 +1,14 @@ export enum ClientType { OpenAI = 'openai', - OpenAICompatible = 'openai-compatible', + OpenAICompat = 'openai-compat', Anthropic = 'anthropic', Google = 'google', + Azure = 'azure', + Bedrock = 'bedrock', + Mistral = 'mistral', + XAI = 'xai', + Ollama = 'ollama', + Dashscope = 'dashscope', } export enum ModelInput { diff --git a/cmd/agent/main.go b/cmd/agent/main.go index 1ec0c4d6..1e7bd32c 100644 --- a/cmd/agent/main.go +++ b/cmd/agent/main.go @@ -401,7 +401,7 @@ type serverParams struct { Logger *slog.Logger RuntimeConfig *boot.RuntimeConfig Config config.Config - ServerHandlers []server.Handler `group:"server_handlers"` + ServerHandlers []server.Handler `group:"server_handlers"` ContainerdHandler *handlers.ContainerdHandler } @@ -610,7 +610,10 @@ func (c *lazyLLMClient) resolve(ctx context.Context) (memory.LLM, error) { return nil, err } clientType := strings.ToLower(strings.TrimSpace(memoryProvider.ClientType)) - if clientType != "openai" && clientType != "openai-compat" { + switch clientType { + case "openai", "openai-compat", "azure", "mistral", "xai", "ollama", "dashscope": + // These providers support OpenAI-compatible /chat/completions endpoint + default: return nil, fmt.Errorf("memory provider client type not supported: %s", memoryProvider.ClientType) } return memory.NewLLMClient(c.logger, memoryProvider.BaseUrl, memoryProvider.ApiKey, memoryModel.ModelID, c.timeout) diff --git a/db/migrations/0001_init.up.sql b/db/migrations/0001_init.up.sql index 812b7865..7f6ec396 100644 --- a/db/migrations/0001_init.up.sql +++ b/db/migrations/0001_init.up.sql @@ -70,7 +70,7 @@ CREATE TABLE IF NOT EXISTS llm_providers ( created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), CONSTRAINT llm_providers_name_unique UNIQUE (name), - CONSTRAINT llm_providers_client_type_check CHECK (client_type IN ('openai', 'openai-compat', 'anthropic', 'google', 'ollama')) + CONSTRAINT llm_providers_client_type_check CHECK (client_type IN ('openai', 'openai-compat', 'anthropic', 'google', 'azure', 'bedrock', 'mistral', 'xai', 'ollama', 'dashscope')) ); CREATE TABLE IF NOT EXISTS models ( diff --git a/db/migrations/0002_channel_identity_avatar.down.sql b/db/migrations/0002_channel_identity_avatar.down.sql new file mode 100644 index 00000000..da834906 --- /dev/null +++ b/db/migrations/0002_channel_identity_avatar.down.sql @@ -0,0 +1,2 @@ +-- 0002_channel_identity_avatar (down) +ALTER TABLE channel_identities DROP COLUMN IF EXISTS avatar_url; diff --git a/db/migrations/0002_channel_identity_avatar.up.sql b/db/migrations/0002_channel_identity_avatar.up.sql new file mode 100644 index 00000000..7684b8a2 --- /dev/null +++ b/db/migrations/0002_channel_identity_avatar.up.sql @@ -0,0 +1,3 @@ +-- 0002_channel_identity_avatar +-- Add avatar_url column to channel_identities for sender profile display. +ALTER TABLE channel_identities ADD COLUMN IF NOT EXISTS avatar_url TEXT; diff --git a/db/queries/channel_identities.sql b/db/queries/channel_identities.sql index 8a761998..715eb418 100644 --- a/db/queries/channel_identities.sql +++ b/db/queries/channel_identities.sql @@ -1,37 +1,38 @@ -- name: CreateChannelIdentity :one -INSERT INTO channel_identities (user_id, channel_type, channel_subject_id, display_name, metadata) -VALUES ($1, $2, $3, $4, $5) -RETURNING id, user_id, channel_type, channel_subject_id, display_name, metadata, created_at, updated_at; +INSERT INTO channel_identities (user_id, channel_type, channel_subject_id, display_name, avatar_url, metadata) +VALUES ($1, $2, $3, $4, $5, $6) +RETURNING id, user_id, channel_type, channel_subject_id, display_name, avatar_url, metadata, created_at, updated_at; -- name: GetChannelIdentityByID :one -SELECT id, user_id, channel_type, channel_subject_id, display_name, metadata, created_at, updated_at +SELECT id, user_id, channel_type, channel_subject_id, display_name, avatar_url, metadata, created_at, updated_at FROM channel_identities WHERE id = $1; -- name: GetChannelIdentityByIDForUpdate :one -SELECT id, user_id, channel_type, channel_subject_id, display_name, metadata, created_at, updated_at +SELECT id, user_id, channel_type, channel_subject_id, display_name, avatar_url, metadata, created_at, updated_at FROM channel_identities WHERE id = $1 FOR UPDATE; -- name: GetChannelIdentityByChannelSubject :one -SELECT id, user_id, channel_type, channel_subject_id, display_name, metadata, created_at, updated_at +SELECT id, user_id, channel_type, channel_subject_id, display_name, avatar_url, metadata, created_at, updated_at FROM channel_identities WHERE channel_type = $1 AND channel_subject_id = $2; -- name: UpsertChannelIdentityByChannelSubject :one -INSERT INTO channel_identities (user_id, channel_type, channel_subject_id, display_name, metadata) -VALUES ($1, $2, $3, $4, $5) +INSERT INTO channel_identities (user_id, channel_type, channel_subject_id, display_name, avatar_url, metadata) +VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT (channel_type, channel_subject_id) DO UPDATE SET display_name = COALESCE(NULLIF(EXCLUDED.display_name, ''), channel_identities.display_name), + avatar_url = COALESCE(NULLIF(EXCLUDED.avatar_url, ''), channel_identities.avatar_url), metadata = EXCLUDED.metadata, user_id = COALESCE(channel_identities.user_id, EXCLUDED.user_id), updated_at = now() -RETURNING id, user_id, channel_type, channel_subject_id, display_name, metadata, created_at, updated_at; +RETURNING id, user_id, channel_type, channel_subject_id, display_name, avatar_url, metadata, created_at, updated_at; -- name: ListChannelIdentitiesByUserID :many -SELECT id, user_id, channel_type, channel_subject_id, display_name, metadata, created_at, updated_at +SELECT id, user_id, channel_type, channel_subject_id, display_name, avatar_url, metadata, created_at, updated_at FROM channel_identities WHERE user_id = $1 ORDER BY created_at DESC; @@ -40,10 +41,10 @@ ORDER BY created_at DESC; UPDATE channel_identities SET user_id = $2, updated_at = now() WHERE id = $1 -RETURNING id, user_id, channel_type, channel_subject_id, display_name, metadata, created_at, updated_at; +RETURNING id, user_id, channel_type, channel_subject_id, display_name, avatar_url, metadata, created_at, updated_at; -- name: ClearChannelIdentityLinkedUser :one UPDATE channel_identities SET user_id = NULL, updated_at = now() WHERE id = $1 -RETURNING id, user_id, channel_type, channel_subject_id, display_name, metadata, created_at, updated_at; +RETURNING id, user_id, channel_type, channel_subject_id, display_name, avatar_url, metadata, created_at, updated_at; diff --git a/db/queries/messages.sql b/db/queries/messages.sql index eaa5a02b..728f2283 100644 --- a/db/queries/messages.sql +++ b/db/queries/messages.sql @@ -39,78 +39,90 @@ RETURNING -- name: ListMessages :many SELECT - id, - bot_id, - route_id, - sender_channel_identity_id, - sender_account_user_id AS sender_user_id, - channel_type AS platform, - source_message_id AS external_message_id, - source_reply_to_message_id, - role, - content, - metadata, - created_at -FROM bot_history_messages -WHERE bot_id = sqlc.arg(bot_id) -ORDER BY created_at ASC; + m.id, + m.bot_id, + m.route_id, + m.sender_channel_identity_id, + m.sender_account_user_id AS sender_user_id, + m.channel_type AS platform, + m.source_message_id AS external_message_id, + m.source_reply_to_message_id, + m.role, + m.content, + m.metadata, + m.created_at, + ci.display_name AS sender_display_name, + ci.avatar_url AS sender_avatar_url +FROM bot_history_messages m +LEFT JOIN channel_identities ci ON ci.id = m.sender_channel_identity_id +WHERE m.bot_id = sqlc.arg(bot_id) +ORDER BY m.created_at ASC; -- name: ListMessagesSince :many SELECT - id, - bot_id, - route_id, - sender_channel_identity_id, - sender_account_user_id AS sender_user_id, - channel_type AS platform, - source_message_id AS external_message_id, - source_reply_to_message_id, - role, - content, - metadata, - created_at -FROM bot_history_messages -WHERE bot_id = sqlc.arg(bot_id) - AND created_at >= sqlc.arg(created_at) -ORDER BY created_at ASC; + m.id, + m.bot_id, + m.route_id, + m.sender_channel_identity_id, + m.sender_account_user_id AS sender_user_id, + m.channel_type AS platform, + m.source_message_id AS external_message_id, + m.source_reply_to_message_id, + m.role, + m.content, + m.metadata, + m.created_at, + ci.display_name AS sender_display_name, + ci.avatar_url AS sender_avatar_url +FROM bot_history_messages m +LEFT JOIN channel_identities ci ON ci.id = m.sender_channel_identity_id +WHERE m.bot_id = sqlc.arg(bot_id) + AND m.created_at >= sqlc.arg(created_at) +ORDER BY m.created_at ASC; -- name: ListMessagesBefore :many SELECT - id, - bot_id, - route_id, - sender_channel_identity_id, - sender_account_user_id AS sender_user_id, - channel_type AS platform, - source_message_id AS external_message_id, - source_reply_to_message_id, - role, - content, - metadata, - created_at -FROM bot_history_messages -WHERE bot_id = sqlc.arg(bot_id) - AND created_at < sqlc.arg(created_at) -ORDER BY created_at DESC + m.id, + m.bot_id, + m.route_id, + m.sender_channel_identity_id, + m.sender_account_user_id AS sender_user_id, + m.channel_type AS platform, + m.source_message_id AS external_message_id, + m.source_reply_to_message_id, + m.role, + m.content, + m.metadata, + m.created_at, + ci.display_name AS sender_display_name, + ci.avatar_url AS sender_avatar_url +FROM bot_history_messages m +LEFT JOIN channel_identities ci ON ci.id = m.sender_channel_identity_id +WHERE m.bot_id = sqlc.arg(bot_id) + AND m.created_at < sqlc.arg(created_at) +ORDER BY m.created_at DESC LIMIT sqlc.arg(max_count); -- name: ListMessagesLatest :many SELECT - id, - bot_id, - route_id, - sender_channel_identity_id, - sender_account_user_id AS sender_user_id, - channel_type AS platform, - source_message_id AS external_message_id, - source_reply_to_message_id, - role, - content, - metadata, - created_at -FROM bot_history_messages -WHERE bot_id = sqlc.arg(bot_id) -ORDER BY created_at DESC + m.id, + m.bot_id, + m.route_id, + m.sender_channel_identity_id, + m.sender_account_user_id AS sender_user_id, + m.channel_type AS platform, + m.source_message_id AS external_message_id, + m.source_reply_to_message_id, + m.role, + m.content, + m.metadata, + m.created_at, + ci.display_name AS sender_display_name, + ci.avatar_url AS sender_avatar_url +FROM bot_history_messages m +LEFT JOIN channel_identities ci ON ci.id = m.sender_channel_identity_id +WHERE m.bot_id = sqlc.arg(bot_id) +ORDER BY m.created_at DESC LIMIT sqlc.arg(max_count); -- name: DeleteMessagesByBot :exec diff --git a/deploy.sh b/deploy.sh index f61ca163..61435ceb 100755 --- a/deploy.sh +++ b/deploy.sh @@ -48,6 +48,12 @@ fi MEMOH_DATA_ROOT="$(pwd)/.data/memoh" mkdir -p "${MEMOH_DATA_ROOT}" export MEMOH_DATA_ROOT + +# Build metadata +export MEMOH_VERSION="${MEMOH_VERSION:-$(git describe --tags --always 2>/dev/null || echo dev)}" +export MEMOH_COMMIT="${MEMOH_COMMIT:-$(git rev-parse --short HEAD 2>/dev/null || echo unknown)}" +export MEMOH_BUILD_TIME="${MEMOH_BUILD_TIME:-$(date -u +%Y-%m-%dT%H:%M:%SZ)}" +echo -e "${GREEN}✓ Version: ${MEMOH_VERSION} (${MEMOH_COMMIT}) built at ${MEMOH_BUILD_TIME}${NC}" if grep -q '^data_root[[:space:]]*=' config.toml; then awk -v path="${MEMOH_DATA_ROOT}" ' $0 ~ /^data_root[[:space:]]*=/ { print "data_root = \"" path "\""; next } diff --git a/docker-compose.yml b/docker-compose.yml index 189513b8..da87fc0a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,7 +1,7 @@ name: "memoh" services: postgres: - image: postgres:18.1-alpine + image: postgres:18-alpine container_name: memoh-postgres environment: POSTGRES_DB: memoh @@ -42,6 +42,10 @@ services: build: context: . dockerfile: docker/Dockerfile.server + args: + - VERSION=${MEMOH_VERSION:-dev} + - COMMIT_HASH=${MEMOH_COMMIT:-unknown} + - BUILD_TIME=${MEMOH_BUILD_TIME:-unknown} container_name: memoh-server pid: host volumes: @@ -49,7 +53,7 @@ services: - /run/containerd/containerd.sock:/run/containerd/containerd.sock - /var/lib/containerd:/var/lib/containerd - server_cni_state:/var/lib/cni - - /app/data:/app/data + - ${MEMOH_DATA_ROOT:-~/.memoh/data}:${MEMOH_DATA_ROOT:-/opt/memoh/data} cap_add: - SYS_ADMIN - NET_ADMIN @@ -58,12 +62,6 @@ services: - apparmor:unconfined ports: - "8080:8080" - healthcheck: - test: ["CMD-SHELL", "netstat -tln | grep :8080 || exit 1 "] - interval: 10s - timeout: 5s - retries: 5 - start_period: 30s depends_on: postgres: condition: service_healthy @@ -83,14 +81,13 @@ services: ports: - "8081:8081" healthcheck: - test: ["CMD-SHELL", "netstat -tln | grep :8081 || exit 1"] + test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://127.0.0.1:8081/health || exit 1"] interval: 10s timeout: 5s retries: 5 start_period: 20s depends_on: - server: - condition: service_healthy + - server restart: unless-stopped networks: - memoh-network @@ -105,17 +102,9 @@ services: container_name: memoh-web ports: - "80:80" - healthcheck: - test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:80 || exit 1"] - interval: 10s - timeout: 5s - retries: 3 - start_period: 10s depends_on: - server: - condition: service_healthy - agent: - condition: service_healthy + - server + - agent restart: unless-stopped networks: - memoh-network diff --git a/docker/Dockerfile.mcp b/docker/Dockerfile.mcp index 1daf0c51..405ee4a9 100644 --- a/docker/Dockerfile.mcp +++ b/docker/Dockerfile.mcp @@ -1,13 +1,17 @@ +# syntax=docker/dockerfile:1 FROM golang:1.25-alpine AS build WORKDIR /src COPY go.mod go.sum ./ -RUN go mod download +RUN --mount=type=cache,target=/go/pkg/mod \ + go mod download COPY . . ARG TARGETARCH ARG COMMIT_HASH=unknown -RUN CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH:-amd64} \ +RUN --mount=type=cache,target=/go/pkg/mod \ + --mount=type=cache,target=/root/.cache/go-build \ + CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH:-amd64} \ go build -trimpath -ldflags "-s -w -X github.com/memohai/memoh/internal/version.CommitHash=${COMMIT_HASH}" -o /out/mcp ./cmd/mcp FROM alpine:latest diff --git a/docker/Dockerfile.server b/docker/Dockerfile.server index 283b7f2c..7a164593 100644 --- a/docker/Dockerfile.server +++ b/docker/Dockerfile.server @@ -1,3 +1,4 @@ +# syntax=docker/dockerfile:1 FROM golang:1.25-alpine AS builder WORKDIR /build @@ -5,12 +6,23 @@ WORKDIR /build RUN apk add --no-cache git make COPY go.mod go.sum ./ -RUN go mod download +RUN --mount=type=cache,target=/go/pkg/mod \ + go mod download COPY . . -RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \ - go build -trimpath -ldflags "-s -w" \ +ARG VERSION=dev +ARG COMMIT_HASH=unknown +ARG BUILD_TIME=unknown + +RUN --mount=type=cache,target=/go/pkg/mod \ + --mount=type=cache,target=/root/.cache/go-build \ + CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \ + go build -trimpath \ + -ldflags "-s -w \ + -X github.com/memohai/memoh/internal/version.Version=${VERSION} \ + -X github.com/memohai/memoh/internal/version.CommitHash=${COMMIT_HASH} \ + -X github.com/memohai/memoh/internal/version.BuildTime=${BUILD_TIME}" \ -o memoh-server ./cmd/agent/main.go FROM alpine:latest diff --git a/docker/Dockerfile.web b/docker/Dockerfile.web index 4f73c4cb..5e185850 100644 --- a/docker/Dockerfile.web +++ b/docker/Dockerfile.web @@ -1,3 +1,4 @@ +# syntax=docker/dockerfile:1 FROM node:25-alpine AS builder WORKDIR /build @@ -8,7 +9,8 @@ COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./ COPY packages ./packages -RUN pnpm install +RUN --mount=type=cache,target=/root/.local/share/pnpm/store \ + pnpm install ARG VITE_API_URL=http://localhost:8080 ARG VITE_AGENT_URL=http://localhost:8081 diff --git a/go.mod b/go.mod index a3f6bbe3..1a206485 100644 --- a/go.mod +++ b/go.mod @@ -17,7 +17,7 @@ require ( github.com/labstack/echo-jwt/v4 v4.4.0 github.com/labstack/echo/v4 v4.15.0 github.com/larksuite/oapi-sdk-go/v3 v3.5.3 - github.com/modelcontextprotocol/go-sdk v1.2.0 + github.com/modelcontextprotocol/go-sdk v1.3.0 github.com/opencontainers/go-digest v1.0.0 github.com/opencontainers/image-spec v1.1.1 github.com/opencontainers/runtime-spec v1.3.0 @@ -25,19 +25,29 @@ require ( github.com/robfig/cron/v3 v3.0.1 github.com/stretchr/testify v1.11.1 github.com/swaggo/swag v1.16.6 + github.com/tmc/langchaingo v0.1.14 go.uber.org/fx v1.24.0 - golang.org/x/crypto v0.47.0 + golang.org/x/crypto v0.48.0 google.golang.org/grpc v1.78.0 gopkg.in/yaml.v3 v3.0.1 ) require ( + cloud.google.com/go v0.116.0 // indirect + cloud.google.com/go/ai v0.7.0 // indirect + cloud.google.com/go/aiplatform v1.69.0 // indirect + cloud.google.com/go/auth v0.14.0 // indirect + cloud.google.com/go/auth/oauth2adapt v0.2.7 // indirect + cloud.google.com/go/compute/metadata v0.9.0 // indirect + cloud.google.com/go/iam v1.2.2 // indirect + cloud.google.com/go/longrunning v0.6.2 // indirect + cloud.google.com/go/vertexai v0.12.0 // indirect cyphar.com/go-pathrs v0.2.3 // indirect github.com/KyleBanks/depth v1.2.1 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect github.com/Microsoft/hcsshim v0.14.0-rc.1 // indirect - github.com/bits-and-blooms/bitset v1.22.0 // indirect - github.com/blevesearch/bleve_index_api v1.2.11 // indirect + github.com/bits-and-blooms/bitset v1.24.4 // indirect + github.com/blevesearch/bleve_index_api v1.3.1 // indirect github.com/blevesearch/geo v0.2.4 // indirect github.com/blevesearch/go-porterstemmer v1.0.3 // indirect github.com/blevesearch/segment v0.9.1 // indirect @@ -57,6 +67,7 @@ require ( github.com/cyphar/filepath-securejoin v0.6.1 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/distribution/reference v0.6.0 // indirect + github.com/dlclark/regexp2 v1.10.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect @@ -72,14 +83,18 @@ require ( github.com/go-openapi/swag/yamlutils v0.25.4 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect + github.com/google/generative-ai-go v0.15.1 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/google/jsonschema-go v0.4.2 // indirect + github.com/google/s2a-go v0.1.9 // indirect + github.com/googleapis/enterprise-certificate-proxy v0.3.4 // indirect + github.com/googleapis/gax-go/v2 v2.14.1 // indirect github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/json-iterator/go v1.1.12 // indirect - github.com/klauspost/compress v1.18.3 // indirect + github.com/klauspost/compress v1.18.4 // indirect github.com/labstack/gommon v0.4.2 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect @@ -92,32 +107,37 @@ require ( github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect github.com/opencontainers/selinux v1.13.1 // indirect - github.com/petermattis/goid v0.0.0-20240813172612-4fcff4a6cae7 // indirect + github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741 // indirect github.com/pkg/errors v0.9.1 // indirect + github.com/pkoukk/tiktoken-go v0.1.6 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect - github.com/sasha-s/go-deadlock v0.3.5 // indirect + github.com/sasha-s/go-deadlock v0.3.6 // indirect github.com/sirupsen/logrus v1.9.4 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasttemplate v1.2.2 // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect go.opencensus.io v0.24.0 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 // indirect - go.opentelemetry.io/otel v1.39.0 // indirect - go.opentelemetry.io/otel/metric v1.39.0 // indirect - go.opentelemetry.io/otel/trace v1.39.0 // indirect + go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 // indirect + go.opentelemetry.io/otel v1.40.0 // indirect + go.opentelemetry.io/otel/metric v1.40.0 // indirect + go.opentelemetry.io/otel/trace v1.40.0 // indirect go.uber.org/dig v1.19.0 // indirect - go.uber.org/multierr v1.10.0 // indirect - go.uber.org/zap v1.26.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + go.uber.org/zap v1.27.1 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/mod v0.32.0 // indirect - golang.org/x/net v0.49.0 // indirect - golang.org/x/oauth2 v0.34.0 // indirect + golang.org/x/mod v0.33.0 // indirect + golang.org/x/net v0.50.0 // indirect + golang.org/x/oauth2 v0.35.0 // indirect golang.org/x/sync v0.19.0 // indirect - golang.org/x/sys v0.40.0 // indirect - golang.org/x/text v0.33.0 // indirect + golang.org/x/sys v0.41.0 // indirect + golang.org/x/text v0.34.0 // indirect golang.org/x/time v0.14.0 // indirect - golang.org/x/tools v0.41.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 // indirect + golang.org/x/tools v0.42.0 // indirect + google.golang.org/api v0.218.0 // indirect + google.golang.org/genproto v0.0.0-20241118233622-e639e219e697 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20251029180050-ab9386a59fda // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 // indirect google.golang.org/protobuf v1.36.11 // indirect ) diff --git a/go.sum b/go.sum index 6283effb..33e332d6 100644 --- a/go.sum +++ b/go.sum @@ -1,4 +1,22 @@ cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.116.0 h1:B3fRrSDkLRt5qSHWe40ERJvhvnQwdZiHu0bJOpldweE= +cloud.google.com/go v0.116.0/go.mod h1:cEPSRWPzZEswwdr9BxE6ChEn01dWlTaF05LiC2Xs70U= +cloud.google.com/go/ai v0.7.0 h1:P6+b5p4gXlza5E+u7uvcgYlzZ7103ACg70YdZeC6oGE= +cloud.google.com/go/ai v0.7.0/go.mod h1:7ozuEcraovh4ABsPbrec3o4LmFl9HigNI3D5haxYeQo= +cloud.google.com/go/aiplatform v1.69.0 h1:XvBzK8e6/6ufbi/i129Vmn/gVqFwbNPmRQ89K+MGlgc= +cloud.google.com/go/aiplatform v1.69.0/go.mod h1:nUsIqzS3khlnWvpjfJbP+2+h+VrFyYsTm7RNCAViiY8= +cloud.google.com/go/auth v0.14.0 h1:A5C4dKV/Spdvxcl0ggWwWEzzP7AZMJSEIgrkngwhGYM= +cloud.google.com/go/auth v0.14.0/go.mod h1:CYsoRL1PdiDuqeQpZE0bP2pnPrGqFcOkI0nldEQis+A= +cloud.google.com/go/auth/oauth2adapt v0.2.7 h1:/Lc7xODdqcEw8IrZ9SvwnlLX6j9FHQM74z6cBk9Rw6M= +cloud.google.com/go/auth/oauth2adapt v0.2.7/go.mod h1:NTbTTzfvPl1Y3V1nPpOgl2w6d/FjO7NNUQaWSox6ZMc= +cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= +cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= +cloud.google.com/go/iam v1.2.2 h1:ozUSofHUGf/F4tCNy/mu9tHLTaxZFLOUiKzjcgWHGIA= +cloud.google.com/go/iam v1.2.2/go.mod h1:0Ys8ccaZHdI1dEUilwzqng/6ps2YB6vRsjIe00/+6JY= +cloud.google.com/go/longrunning v0.6.2 h1:xjDfh1pQcWPEvnfjZmwjKQEcHnpz6lHjfy7Fo0MK+hc= +cloud.google.com/go/longrunning v0.6.2/go.mod h1:k/vIs83RN4bE3YCswdXC5PFfWVILjm3hpEUlSko4PiI= +cloud.google.com/go/vertexai v0.12.0 h1:zTadEo/CtsoyRXNx3uGCncoWAP1H2HakGqwznt+iMo8= +cloud.google.com/go/vertexai v0.12.0/go.mod h1:8u+d0TsvBfAAd2x5R6GMgbYhsLgo3J7lmP4bR8g2ig8= cyphar.com/go-pathrs v0.2.3 h1:0pH8gep37wB0BgaXrEaN1OtZhUMeS7VvaejSr6i822o= cyphar.com/go-pathrs v0.2.3/go.mod h1:y8f1EMG7r+hCuFf/rXsKqMJrJAUoADZGNh5/vZPKcGc= github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk= @@ -12,12 +30,12 @@ github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERo github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/Microsoft/hcsshim v0.14.0-rc.1 h1:qAPXKwGOkVn8LlqgBN8GS0bxZ83hOJpcjxzmlQKxKsQ= github.com/Microsoft/hcsshim v0.14.0-rc.1/go.mod h1:hTKFGbnDtQb1wHiOWv4v0eN+7boSWAHyK/tNAaYZL0c= -github.com/bits-and-blooms/bitset v1.22.0 h1:Tquv9S8+SGaS3EhyA+up3FXzmkhxPGjQQCkcs2uw7w4= -github.com/bits-and-blooms/bitset v1.22.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= +github.com/bits-and-blooms/bitset v1.24.4 h1:95H15Og1clikBrKr/DuzMXkQzECs1M6hhoGXLwLQOZE= +github.com/bits-and-blooms/bitset v1.24.4/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= github.com/blevesearch/bleve/v2 v2.5.7 h1:2d9YrL5zrX5EBBW++GOaEKjE+NPWeZGaX77IM26m1Z8= github.com/blevesearch/bleve/v2 v2.5.7/go.mod h1:yj0NlS7ocGC4VOSAedqDDMktdh2935v2CSWOCDMHdSA= -github.com/blevesearch/bleve_index_api v1.2.11 h1:bXQ54kVuwP8hdrXUSOnvTQfgK0KI1+f9A0ITJT8tX1s= -github.com/blevesearch/bleve_index_api v1.2.11/go.mod h1:rKQDl4u51uwafZxFrPD1R7xFOwKnzZW7s/LSeK4lgo0= +github.com/blevesearch/bleve_index_api v1.3.1 h1:LdH3CQgBbIZ5UI/5Pykz87e0jfeQtVnrdZ2WUBrHHwU= +github.com/blevesearch/bleve_index_api v1.3.1/go.mod h1:xvd48t5XMeeioWQ5/jZvgLrV98flT2rdvEJ3l/ki4Ko= github.com/blevesearch/geo v0.2.4 h1:ECIGQhw+QALCZaDcogRTNSJYQXRtC8/m8IKiA706cqk= github.com/blevesearch/geo v0.2.4/go.mod h1:K56Q33AzXt2YExVHGObtmRSFYZKYGv0JEN5mdacJJR8= github.com/blevesearch/go-porterstemmer v1.0.3 h1:GtmsqID0aZdCSNiY8SkuPJ12pD4jI+DdXTAn4YRcHCo= @@ -35,6 +53,8 @@ github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UF github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= +github.com/cncf/xds/go v0.0.0-20251022180443-0feb69152e9f h1:Y8xYupdHxryycyPlc9Y+bSQAYZnetRJ70VMVKm5CKI0= +github.com/cncf/xds/go v0.0.0-20251022180443-0feb69152e9f/go.mod h1:HlzOvOjVBOfTGSRXRyY0OiCS/3J1akRGQQpRO/7zyF4= github.com/containerd/cgroups/v3 v3.1.2 h1:OSosXMtkhI6Qove637tg1XgK4q+DhR0mX8Wi8EhrHa4= github.com/containerd/cgroups/v3 v3.1.2/go.mod h1:PKZ2AcWmSBsY/tJUVhtS/rluX0b1uq1GmPO1ElCmbOw= github.com/containerd/containerd/api v1.10.0 h1:5n0oHYVBwN4VhoX9fFykCV9dF1/BvAXeg2F8W6UYq1o= @@ -71,10 +91,17 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/dlclark/regexp2 v1.10.0 h1:+/GIL799phkJqYW+3YbOd8LCcbHzT0Pbo8zl70MHsq0= +github.com/dlclark/regexp2 v1.10.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= +github.com/envoyproxy/go-control-plane v0.13.5-0.20251024222203-75eaa193e329 h1:K+fnvUM0VZ7ZFJf0n4L/BRlnsb9pL/GuDG6FqaH+PwM= +github.com/envoyproxy/go-control-plane/envoy v1.35.0 h1:ixjkELDE+ru6idPxcHLj8LBVc2bFP7iBytj353BoHUo= +github.com/envoyproxy/go-control-plane/envoy v1.35.0/go.mod h1:09qwbGVuSWWAyN5t/b3iyVfz5+z8QWGrzkoqm/8SbEs= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= +github.com/envoyproxy/protoc-gen-validate v1.2.1 h1:DEo3O99U8j4hBFwbJfrz9VtgcDfUKS7KJ7spH3d86P8= +github.com/envoyproxy/protoc-gen-validate v1.2.1/go.mod h1:d/C80l/jxXLdfEIhX1W2TmLfsJ31lvEjwamM4DxlWXU= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= @@ -88,7 +115,7 @@ github.com/go-openapi/jsonreference v0.21.4 h1:24qaE2y9bx/q3uRK/qN+TDwbok1NhbSmG github.com/go-openapi/jsonreference v0.21.4/go.mod h1:rIENPTjDbLpzQmQWCj5kKj3ZlmEh+EFVbz3RTUh30/4= github.com/go-openapi/spec v0.22.3 h1:qRSmj6Smz2rEBxMnLRBMeBWxbbOvuOoElvSvObIgwQc= github.com/go-openapi/spec v0.22.3/go.mod h1:iIImLODL2loCh3Vnox8TY2YWYJZjMAKYyLH2Mu8lOZs= -github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM= +github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= github.com/go-openapi/swag/conv v0.25.4 h1:/Dd7p0LZXczgUcC/Ikm1+YqVzkEeCc9LnOWjfkpkfe4= github.com/go-openapi/swag/conv v0.25.4/go.mod h1:3LXfie/lwoAv0NHoEuY1hjoFAYkvlqI/Bn5EQDD3PPU= github.com/go-openapi/swag/jsonname v0.25.4 h1:bZH0+MsS03MbnwBXYhuTttMOqk+5KcQ9869Vye1bNHI= @@ -133,6 +160,8 @@ github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QD github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/generative-ai-go v0.15.1 h1:n8aQUpvhPOlGVuM2DRkJ2jvx04zpp42B778AROJa+pQ= +github.com/google/generative-ai-go v0.15.1/go.mod h1:AAucpWZjXsDKhQYWvCYuP6d0yB1kX998pJlOW1rAesw= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= @@ -146,9 +175,15 @@ github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbc github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/google/pprof v0.0.0-20240727154555-813a5fbdbec8 h1:FKHo8hFI3A+7w0aUQuYXQ+6EN5stWmeY/AZqtM8xk9k= github.com/google/pprof v0.0.0-20240727154555-813a5fbdbec8/go.mod h1:K1liHPHnj73Fdn/EKuT8nrFqBihUSKXoLYU0BuatOYo= +github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= +github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/googleapis/enterprise-certificate-proxy v0.3.4 h1:XYIDZApgAnrN1c855gTgghdIA6Stxb52D5RnLI1SLyw= +github.com/googleapis/enterprise-certificate-proxy v0.3.4/go.mod h1:YKe7cfqYXjKGpGvmSg28/fFvhNzinZQm8DGnaburhGA= +github.com/googleapis/gax-go/v2 v2.14.1 h1:hb0FFeiPaQskmvakKu5EbCbpntQn48jyHuvrkurSS/Q= +github.com/googleapis/gax-go/v2 v2.14.1/go.mod h1:Hb/NubMaVM88SrNkvl8X/o8XWwDJEPqouaLeN2IUxoA= github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo= github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA= @@ -164,8 +199,8 @@ github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnr github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= -github.com/klauspost/compress v1.18.3 h1:9PJRvfbmTabkOX8moIpXPbMMbYN60bWImDDU7L+/6zw= -github.com/klauspost/compress v1.18.3/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= +github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c= +github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= @@ -194,8 +229,8 @@ github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs= github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs= github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g= github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= -github.com/modelcontextprotocol/go-sdk v1.2.0 h1:Y23co09300CEk8iZ/tMxIX1dVmKZkzoSBZOpJwUnc/s= -github.com/modelcontextprotocol/go-sdk v1.2.0/go.mod h1:6fM3LCm3yV7pAs8isnKLn07oKtB0MP9LHd3DfAcKw10= +github.com/modelcontextprotocol/go-sdk v1.3.0 h1:gMfZkv3DzQF5q/DcQePo5rahEY+sguyPfXDfNBcT0Zs= +github.com/modelcontextprotocol/go-sdk v1.3.0/go.mod h1:AnQ//Qc6+4nIyyrB4cxBU7UW9VibK4iOZBeyP/rF1IE= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -214,10 +249,15 @@ github.com/opencontainers/runtime-spec v1.3.0 h1:YZupQUdctfhpZy3TM39nN9Ika5CBWT5 github.com/opencontainers/runtime-spec v1.3.0/go.mod h1:jwyrGlmzljRJv/Fgzds9SsS/C5hL+LL3ko9hs6T5lQ0= github.com/opencontainers/selinux v1.13.1 h1:A8nNeceYngH9Ow++M+VVEwJVpdFmrlxsN22F+ISDCJE= github.com/opencontainers/selinux v1.13.1/go.mod h1:S10WXZ/osk2kWOYKy1x2f/eXF5ZHJoUs8UU/2caNRbg= -github.com/petermattis/goid v0.0.0-20240813172612-4fcff4a6cae7 h1:Dx7Ovyv/SFnMFw3fD4oEoeorXc6saIiQ23LrGLth0Gw= -github.com/petermattis/goid v0.0.0-20240813172612-4fcff4a6cae7/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4= +github.com/petermattis/goid v0.0.0-20250813065127-a731cc31b4fe/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4= +github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741 h1:KPpdlQLZcHfTMQRi6bFQ7ogNO0ltFT4PmtwTLW4W+14= +github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkoukk/tiktoken-go v0.1.6 h1:JF0TlJzhTbrI30wCvFuiw6FzP2+/bR+FIxUdgEAcUsw= +github.com/pkoukk/tiktoken-go v0.1.6/go.mod h1:9NiV+i9mJKGj1rYOT+njbv+ZwA/zJxYdewGl6qVatpg= +github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo= +github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -230,8 +270,8 @@ github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= -github.com/sasha-s/go-deadlock v0.3.5 h1:tNCOEEDG6tBqrNDOX35j/7hL5FcFViG6awUGROb2NsU= -github.com/sasha-s/go-deadlock v0.3.5/go.mod h1:bugP6EGbdGYObIlx7pUZtWqlvo8k9H6vCBBsiChJQ5U= +github.com/sasha-s/go-deadlock v0.3.6 h1:TR7sfOnZ7x00tWPfD397Peodt57KzMDo+9Ae9rMiUmw= +github.com/sasha-s/go-deadlock v0.3.6/go.mod h1:CUqNyyvMxTyjFqDT7MRg9mb4Dv/btmGTqSR+rky/UXo= github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= @@ -248,6 +288,8 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI= github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg= +github.com/tmc/langchaingo v0.1.14 h1:o1qWBPigAIuFvrG6cjTFo0cZPFEZ47ZqpOYMjM15yZc= +github.com/tmc/langchaingo v0.1.14/go.mod h1:aKKYXYoqhIDEv7WKdpnnCLRaqXic69cX9MnDUk72378= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= @@ -262,35 +304,39 @@ go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 h1:ssfIgGNANqpVFCndZvcuyKbl0g+UAVcbBcqGkG28H0Y= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0/go.mod h1:GQ/474YrbE4Jx8gZ4q5I4hrhUzM6UPzyrqJYV2AqPoQ= -go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= -go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= -go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= -go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= -go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= -go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE= -go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8= -go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= -go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= -go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0 h1:x7wzEgXfnzJcHDwStJT+mxOz4etr2EcexjqhBvmoakw= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0/go.mod h1:rg+RlpR5dKwaS95IyyZqj5Wd4E13lk/msnTS0Xl9lJM= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 h1:7iP2uCb7sGddAr30RRS6xjKy7AZ2JtTOPA3oolgVSw8= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0/go.mod h1:c7hN3ddxs/z6q9xwvfLPk+UHlWRQyaeR1LdgfL/66l0= +go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms= +go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g= +go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g= +go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc= +go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8= +go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE= +go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw= +go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg= +go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw= +go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA= go.uber.org/dig v1.19.0 h1:BACLhebsYdpQ7IROQ1AGPjrXcP5dF80U3gKoFzbaq/4= go.uber.org/dig v1.19.0/go.mod h1:Us0rSJiThwCv2GteUN0Q7OKvU7n5J4dxZ9JKUXozFdE= go.uber.org/fx v1.24.0 h1:wE8mruvpg2kiiL1Vqd0CC+tr0/24XIB10Iwp2lLWzkg= go.uber.org/fx v1.24.0/go.mod h1:AmDeGyS+ZARGKM4tlH4FY2Jr63VjbEDJHtqXTGP5hbo= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= -go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= -go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= -go.uber.org/zap v1.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo= -go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= +go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= +go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= -golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20241108190413-2d47ceb2692f h1:XdNn9LlyWAhLVp6P/i8QYBW+hlyhrhei9uErw2B5GJo= golang.org/x/exp v0.0.0-20241108190413-2d47ceb2692f/go.mod h1:D5SMRVC3C2/4+F/DB1wZsLRnSNimn2Sp/NPsCrsv8ak= @@ -299,8 +345,8 @@ golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvx golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= -golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= +golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= +golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -310,11 +356,11 @@ golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLL golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= -golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= +golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= +golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= -golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= -golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= +golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -327,13 +373,13 @@ golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5h golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= -golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= -golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -344,21 +390,27 @@ golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBn golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= -golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= +golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= +golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +google.golang.org/api v0.218.0 h1:x6JCjEWeZ9PFCRe9z0FBrNwj7pB7DOAqT35N+IPnAUA= +google.golang.org/api v0.218.0/go.mod h1:5VGHBAkxrA/8EFjLVEYmMUJ8/8+gWWQ3s4cFH0FxG2M= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 h1:H86B94AW+VfJWDqFeEbBPhEtHzJwJfTbgE2lZa54ZAQ= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= +google.golang.org/genproto v0.0.0-20241118233622-e639e219e697 h1:ToEetK57OidYuqD4Q5w+vfEnPvPpuTwedCNVohYJfNk= +google.golang.org/genproto v0.0.0-20241118233622-e639e219e697/go.mod h1:JJrvXBWRZaFMxBufik1a4RpFw4HhgVtBBWQeQgUj2cc= +google.golang.org/genproto/googleapis/api v0.0.0-20251029180050-ab9386a59fda h1:+2XxjfsAu6vqFxwGBRcHiMaDCuZiqXGDUDVWVtrFAnE= +google.golang.org/genproto/googleapis/api v0.0.0-20251029180050-ab9386a59fda/go.mod h1:fDMmzKV90WSg1NbozdqrE64fkuTv6mlq2zxo9ad+3yo= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 h1:mWPCjDEyshlQYzBpMNHaEof6UX1PmHcaUODUywQ0uac= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= @@ -385,3 +437,5 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= +sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= diff --git a/internal/bind/service_integration_test.go b/internal/bind/service_consume_integration_test.go similarity index 56% rename from internal/bind/service_integration_test.go rename to internal/bind/service_consume_integration_test.go index b6f0f5ec..735eddb9 100644 --- a/internal/bind/service_integration_test.go +++ b/internal/bind/service_consume_integration_test.go @@ -19,7 +19,7 @@ import ( "github.com/memohai/memoh/internal/db/sqlc" ) -func setupBindIntegrationTest(t *testing.T) (*sqlc.Queries, *identities.Service, *bind.Service, func()) { +func setupBindConsumeIntegrationTest(t *testing.T) (*sqlc.Queries, *identities.Service, *bind.Service, func()) { t.Helper() dsn := os.Getenv("TEST_POSTGRES_DSN") @@ -77,8 +77,8 @@ func createBotForBind(ctx context.Context, queries *sqlc.Queries, ownerUserID st return row.ID.String(), nil } -func TestIntegrationConsumeBindCodeSuccessAndSingleUse(t *testing.T) { - queries, channelIdentitySvc, bindSvc, cleanup := setupBindIntegrationTest(t) +func TestBindConsumeLinksChannelIdentityToIssuerUser(t *testing.T) { + queries, channelIdentitySvc, bindSvc, cleanup := setupBindConsumeIntegrationTest(t) defer cleanup() ctx := context.Background() @@ -86,91 +86,7 @@ func TestIntegrationConsumeBindCodeSuccessAndSingleUse(t *testing.T) { if err != nil { t.Fatalf("create owner user failed: %v", err) } - sourceChannelIdentity, err := channelIdentitySvc.Create(ctx, "feishu", fmt.Sprintf("bind-success-%d", time.Now().UnixNano()), "source") - if err != nil { - t.Fatalf("create source channel identity failed: %v", err) - } - - code, err := bindSvc.Issue(ctx, ownerUserID, "feishu", 10*time.Minute) - if err != nil { - t.Fatalf("issue bind code failed: %v", err) - } - if err := bindSvc.Consume(ctx, code, sourceChannelIdentity.ID); err != nil { - t.Fatalf("consume bind code failed: %v", err) - } - - after, err := bindSvc.Get(ctx, code.Token) - if err != nil { - t.Fatalf("get bind code failed: %v", err) - } - if after.UsedAt.IsZero() { - t.Fatal("expected used_at to be set after consume") - } - if after.UsedByChannelIdentityID != sourceChannelIdentity.ID { - t.Fatalf("expected used_by_channel_identity_id=%s, got %s", sourceChannelIdentity.ID, after.UsedByChannelIdentityID) - } - - linkedUserID, err := channelIdentitySvc.GetLinkedUserID(ctx, sourceChannelIdentity.ID) - if err != nil { - t.Fatalf("get linked user failed: %v", err) - } - if linkedUserID != ownerUserID { - t.Fatalf("expected linked user=%s, got %s", ownerUserID, linkedUserID) - } - - if err := bindSvc.Consume(ctx, code, sourceChannelIdentity.ID); !errors.Is(err, bind.ErrCodeUsed) { - t.Fatalf("expected ErrCodeUsed on second consume, got %v", err) - } -} - -func TestIntegrationConsumeBindCodeRollbackOnLinkConflict(t *testing.T) { - queries, channelIdentitySvc, bindSvc, cleanup := setupBindIntegrationTest(t) - defer cleanup() - - ctx := context.Background() - ownerUserID, err := createUserForBind(ctx, queries) - if err != nil { - t.Fatalf("create owner user failed: %v", err) - } - otherUserID, err := createUserForBind(ctx, queries) - if err != nil { - t.Fatalf("create other user failed: %v", err) - } - sourceChannelIdentity, err := channelIdentitySvc.Create(ctx, "feishu", fmt.Sprintf("bind-rollback-%d", time.Now().UnixNano()), "source") - if err != nil { - t.Fatalf("create source channel identity failed: %v", err) - } - if err := channelIdentitySvc.LinkChannelIdentityToUser(ctx, sourceChannelIdentity.ID, otherUserID); err != nil { - t.Fatalf("pre-link source channel identity failed: %v", err) - } - - code, err := bindSvc.Issue(ctx, ownerUserID, "feishu", 10*time.Minute) - if err != nil { - t.Fatalf("issue bind code failed: %v", err) - } - if err := bindSvc.Consume(ctx, code, sourceChannelIdentity.ID); !errors.Is(err, bind.ErrLinkConflict) { - t.Fatalf("expected ErrLinkConflict, got %v", err) - } - - after, err := bindSvc.Get(ctx, code.Token) - if err != nil { - t.Fatalf("get bind code failed: %v", err) - } - if !after.UsedAt.IsZero() { - t.Fatal("expected used_at to remain empty when consume fails") - } -} - -func TestIntegrationConsumeLinksChannelIdentityToIssuerUser(t *testing.T) { - queries, channelIdentitySvc, bindSvc, cleanup := setupBindIntegrationTest(t) - defer cleanup() - - ctx := context.Background() - ownerUserID, err := createUserForBind(ctx, queries) - if err != nil { - t.Fatalf("create owner user failed: %v", err) - } - sourceChannelIdentity, err := channelIdentitySvc.ResolveByChannelIdentity(ctx, "feishu", fmt.Sprintf("bind-src-%d", time.Now().UnixNano()), "source") + sourceChannelIdentity, err := channelIdentitySvc.ResolveByChannelIdentity(ctx, "feishu", fmt.Sprintf("bind-src-%d", time.Now().UnixNano()), "source", nil) if err != nil { t.Fatalf("create source channelIdentity failed: %v", err) } @@ -202,8 +118,8 @@ func TestIntegrationConsumeLinksChannelIdentityToIssuerUser(t *testing.T) { } } -func TestIntegrationConsumeConflictDoesNotMarkUsed(t *testing.T) { - queries, channelIdentitySvc, bindSvc, cleanup := setupBindIntegrationTest(t) +func TestBindConsumeConflictDoesNotMarkUsed(t *testing.T) { + queries, channelIdentitySvc, bindSvc, cleanup := setupBindConsumeIntegrationTest(t) defer cleanup() ctx := context.Background() @@ -215,7 +131,7 @@ func TestIntegrationConsumeConflictDoesNotMarkUsed(t *testing.T) { if err != nil { t.Fatalf("create other user failed: %v", err) } - sourceChannelIdentity, err := channelIdentitySvc.ResolveByChannelIdentity(ctx, "feishu", fmt.Sprintf("bind-conflict-%d", time.Now().UnixNano()), "source") + sourceChannelIdentity, err := channelIdentitySvc.ResolveByChannelIdentity(ctx, "feishu", fmt.Sprintf("bind-conflict-%d", time.Now().UnixNano()), "source", nil) if err != nil { t.Fatalf("create source channelIdentity failed: %v", err) } diff --git a/internal/channel/adapter.go b/internal/channel/adapter.go index 0b12cf07..c04f5f1f 100644 --- a/internal/channel/adapter.go +++ b/internal/channel/adapter.go @@ -103,6 +103,12 @@ type MessageEditor interface { Unsend(ctx context.Context, cfg ChannelConfig, target string, messageID string) error } +// SelfDiscoverer retrieves the adapter bot's own identity from the platform. +// The returned map is merged into ChannelConfig.SelfIdentity and persisted. +type SelfDiscoverer interface { + DiscoverSelf(ctx context.Context, credentials map[string]any) (identity map[string]any, externalID string, err error) +} + // Receiver is an adapter capable of establishing a long-lived connection to receive messages. type Receiver interface { Connect(ctx context.Context, cfg ChannelConfig, handler InboundHandler) (Connection, error) diff --git a/internal/channel/adapters/feishu/feishu.go b/internal/channel/adapters/feishu/feishu.go index d07be6a8..702a6938 100644 --- a/internal/channel/adapters/feishu/feishu.go +++ b/internal/channel/adapters/feishu/feishu.go @@ -230,6 +230,48 @@ func removeProcessingReaction(ctx context.Context, gateway processingReactionGat return gateway.Remove(ctx, msgID, rxID) } +// DiscoverSelf retrieves the bot's own identity from the Feishu platform. +func (a *FeishuAdapter) DiscoverSelf(ctx context.Context, credentials map[string]any) (map[string]any, string, error) { + cfg, err := parseConfig(credentials) + if err != nil { + return nil, "", err + } + client := lark.NewClient(cfg.AppID, cfg.AppSecret) + resp, err := client.Get(ctx, "/open-apis/bot/v3/info", nil, larkcore.AccessTokenTypeTenant) + if err != nil { + return nil, "", fmt.Errorf("feishu discover self: %w", err) + } + var body struct { + Code int `json:"code"` + Msg string `json:"msg"` + Bot struct { + OpenID string `json:"open_id"` + AppName string `json:"app_name"` + AvatarURL string `json:"avatar_url"` + } `json:"bot"` + } + if err := json.Unmarshal(resp.RawBody, &body); err != nil { + return nil, "", fmt.Errorf("feishu discover self: parse response: %w", err) + } + if body.Code != 0 { + return nil, "", fmt.Errorf("feishu discover self: %s (code: %d)", body.Msg, body.Code) + } + openID := strings.TrimSpace(body.Bot.OpenID) + if openID == "" { + return nil, "", fmt.Errorf("feishu discover self: empty open_id") + } + identity := map[string]any{ + "open_id": openID, + } + if name := strings.TrimSpace(body.Bot.AppName); name != "" { + identity["name"] = name + } + if avatar := strings.TrimSpace(body.Bot.AvatarURL); avatar != "" { + identity["avatar_url"] = avatar + } + return identity, openID, nil +} + // NormalizeConfig validates and normalizes a Feishu channel configuration map. func (a *FeishuAdapter) NormalizeConfig(raw map[string]any) (map[string]any, error) { return normalizeConfig(raw) @@ -272,6 +314,19 @@ func (a *FeishuAdapter) Connect(ctx context.Context, cfg channel.ChannelConfig, } return nil, err } + botOpenID := channel.ReadString(cfg.SelfIdentity, "open_id") + if botOpenID == "" { + if discovered, _, err := a.DiscoverSelf(ctx, cfg.Credentials); err == nil { + if id, ok := discovered["open_id"].(string); ok { + botOpenID = strings.TrimSpace(id) + } + } else if a.logger != nil { + a.logger.Warn("discover self fallback failed", slog.String("config_id", cfg.ID), slog.Any("error", err)) + } + } + if a.logger != nil { + a.logger.Info("bot identity", slog.String("config_id", cfg.ID), slog.String("bot_open_id", botOpenID)) + } connCtx, cancel := context.WithCancel(ctx) newClient := func() *larkws.Client { eventDispatcher := dispatcher.NewEventDispatcher( @@ -282,7 +337,7 @@ func (a *FeishuAdapter) Connect(ctx context.Context, cfg channel.ChannelConfig, if connCtx.Err() != nil { return nil } - msg := extractFeishuInbound(event) + msg := extractFeishuInbound(event, botOpenID) text := msg.Message.PlainText() rawMessageID := "" rawMessageType := "" diff --git a/internal/channel/adapters/feishu/feishu_integration_test.go b/internal/channel/adapters/feishu/feishu_integration_test.go index 35e4fe56..d749556b 100644 --- a/internal/channel/adapters/feishu/feishu_integration_test.go +++ b/internal/channel/adapters/feishu/feishu_integration_test.go @@ -106,3 +106,32 @@ func TestFeishuGateway_Integration(t *testing.T) { } } } + +// TestFeishuDiscoverSelf_Integration verifies the bot info API call. +// Required env: FEISHU_APP_ID, FEISHU_APP_SECRET. +func TestFeishuDiscoverSelf_Integration(t *testing.T) { + appID := os.Getenv("FEISHU_APP_ID") + appSecret := os.Getenv("FEISHU_APP_SECRET") + if appID == "" || appSecret == "" { + t.Skip("skipping integration test: FEISHU_APP_ID or FEISHU_APP_SECRET not set") + } + adapter := NewFeishuAdapter(nil) + credentials := map[string]any{ + "app_id": appID, + "app_secret": appSecret, + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + identity, extID, err := adapter.DiscoverSelf(ctx, credentials) + if err != nil { + t.Fatalf("discover self failed: %v", err) + } + openID, _ := identity["open_id"].(string) + if openID == "" { + t.Fatalf("expected non-empty open_id") + } + if extID != openID { + t.Fatalf("expected external_id=%s, got %s", openID, extID) + } + t.Logf("bot identity: %+v", identity) +} diff --git a/internal/channel/adapters/feishu/feishu_test.go b/internal/channel/adapters/feishu/feishu_test.go index 77847386..42da2b86 100644 --- a/internal/channel/adapters/feishu/feishu_test.go +++ b/internal/channel/adapters/feishu/feishu_test.go @@ -100,7 +100,7 @@ func TestExtractFeishuInboundP2P(t *testing.T) { }, }, } - got := extractFeishuInbound(event) + got := extractFeishuInbound(event, "") if got.Message.PlainText() != "hi" { t.Fatalf("unexpected text: %s", got.Message.PlainText()) } @@ -149,7 +149,7 @@ func TestExtractFeishuInboundGroup(t *testing.T) { }, }, } - got := extractFeishuInbound(event) + got := extractFeishuInbound(event, "ou_bot") if got.ReplyTarget != "chat_id:oc_2" { t.Fatalf("unexpected reply target: %s", got.ReplyTarget) } @@ -169,7 +169,7 @@ func TestExtractFeishuInboundNonText(t *testing.T) { }, }, } - got := extractFeishuInbound(event) + got := extractFeishuInbound(event, "") if got.Message.PlainText() != "" { t.Fatalf("expected empty text, got %s", got.Message.PlainText()) } @@ -188,7 +188,7 @@ func TestExtractFeishuInboundImageAttachmentReference(t *testing.T) { }, }, } - got := extractFeishuInbound(event) + got := extractFeishuInbound(event, "") if len(got.Message.Attachments) != 1 { t.Fatalf("expected one attachment, got %d", len(got.Message.Attachments)) } @@ -278,7 +278,7 @@ func TestProcessFeishuCardMarkdown(t *testing.T) { } } -func TestExtractFeishuInboundMention(t *testing.T) { +func TestExtractFeishuInboundMentionFallbackNoBotID(t *testing.T) { t.Parallel() text := `{"text":"@bot hi","mentions":[{"key":"@bot"}]}` @@ -295,21 +295,25 @@ func TestExtractFeishuInboundMention(t *testing.T) { }, }, } - got := extractFeishuInbound(event) + got := extractFeishuInbound(event, "") mentioned, ok := got.Metadata["is_mentioned"].(bool) if !ok || !mentioned { - t.Fatalf("expected mention flag to be true") + t.Fatalf("expected mention flag to be true (fallback)") } } -func TestExtractFeishuInboundMentionFromEventMentions(t *testing.T) { +func TestExtractFeishuInboundMentionBotMatched(t *testing.T) { t.Parallel() text := `{"text":"hello"}` msgType := larkim.MsgTypeText chatType := "group" chatID := "oc_mention_event" - mention := larkim.NewMentionEventBuilder().Key("@_user_1").Build() + botOpenID := "ou_bot_123" + mention := larkim.NewMentionEventBuilder(). + Key("@_user_1"). + Id(larkim.NewUserIdBuilder().OpenId(botOpenID).Build()). + Build() event := &larkim.P2MessageReceiveV1{ Event: &larkim.P2MessageReceiveV1Data{ Message: &larkim.EventMessage{ @@ -321,14 +325,43 @@ func TestExtractFeishuInboundMentionFromEventMentions(t *testing.T) { }, }, } - got := extractFeishuInbound(event) + got := extractFeishuInbound(event, botOpenID) mentioned, ok := got.Metadata["is_mentioned"].(bool) if !ok || !mentioned { - t.Fatalf("expected mention flag from event mentions") + t.Fatalf("expected mention flag when bot is mentioned") } } -func TestExtractFeishuInboundPostMention(t *testing.T) { +func TestExtractFeishuInboundMentionOtherUserIgnored(t *testing.T) { + t.Parallel() + + text := `{"text":"hello"}` + msgType := larkim.MsgTypeText + chatType := "group" + chatID := "oc_mention_other" + otherOpenID := "ou_other_user" + mention := larkim.NewMentionEventBuilder(). + Key("@_user_1"). + Id(larkim.NewUserIdBuilder().OpenId(otherOpenID).Build()). + Build() + event := &larkim.P2MessageReceiveV1{ + Event: &larkim.P2MessageReceiveV1Data{ + Message: &larkim.EventMessage{ + MessageType: &msgType, + Content: &text, + ChatType: &chatType, + ChatId: &chatID, + Mentions: []*larkim.MentionEvent{mention}, + }, + }, + } + got := extractFeishuInbound(event, "ou_bot_123") + if mentioned, _ := got.Metadata["is_mentioned"].(bool); mentioned { + t.Fatalf("expected no mention flag when another user is mentioned") + } +} + +func TestExtractFeishuInboundPostMentionFallback(t *testing.T) { t.Parallel() content := `{"zh_cn":{"title":"","content":[[{"tag":"at","user_name":"bot"},{"tag":"text","text":" hi"}]]}}` @@ -345,14 +378,61 @@ func TestExtractFeishuInboundPostMention(t *testing.T) { }, }, } - - got := extractFeishuInbound(event) + got := extractFeishuInbound(event, "") if got.Message.PlainText() == "" { t.Fatalf("expected post message to be converted into text") } mentioned, ok := got.Metadata["is_mentioned"].(bool) if !ok || !mentioned { - t.Fatalf("expected mention flag for post message") + t.Fatalf("expected mention flag for post message (fallback)") + } +} + +func TestExtractFeishuInboundPostMentionBotMatched(t *testing.T) { + t.Parallel() + + botOpenID := "ou_bot_123" + content := `{"zh_cn":{"title":"","content":[[{"tag":"at","user_id":"ou_bot_123"},{"tag":"text","text":" hi"}]]}}` + msgType := larkim.MsgTypePost + chatType := "group" + chatID := "oc_post_bot" + event := &larkim.P2MessageReceiveV1{ + Event: &larkim.P2MessageReceiveV1Data{ + Message: &larkim.EventMessage{ + MessageType: &msgType, + Content: &content, + ChatType: &chatType, + ChatId: &chatID, + }, + }, + } + got := extractFeishuInbound(event, botOpenID) + mentioned, ok := got.Metadata["is_mentioned"].(bool) + if !ok || !mentioned { + t.Fatalf("expected mention flag for post with bot user_id") + } +} + +func TestExtractFeishuInboundPostMentionOtherIgnored(t *testing.T) { + t.Parallel() + + content := `{"zh_cn":{"title":"","content":[[{"tag":"at","user_id":"ou_someone_else"},{"tag":"text","text":" hi"}]]}}` + msgType := larkim.MsgTypePost + chatType := "group" + chatID := "oc_post_other" + event := &larkim.P2MessageReceiveV1{ + Event: &larkim.P2MessageReceiveV1Data{ + Message: &larkim.EventMessage{ + MessageType: &msgType, + Content: &content, + ChatType: &chatType, + ChatId: &chatID, + }, + }, + } + got := extractFeishuInbound(event, "ou_bot_123") + if mentioned, _ := got.Metadata["is_mentioned"].(bool); mentioned { + t.Fatalf("expected no mention for post mentioning other user") } } diff --git a/internal/channel/adapters/feishu/inbound.go b/internal/channel/adapters/feishu/inbound.go index 1c32aa5c..eb49d36a 100644 --- a/internal/channel/adapters/feishu/inbound.go +++ b/internal/channel/adapters/feishu/inbound.go @@ -12,7 +12,8 @@ import ( ) // extractFeishuInbound converts a Feishu P2MessageReceiveV1 event into a channel.InboundMessage. -func extractFeishuInbound(event *larkim.P2MessageReceiveV1) channel.InboundMessage { +// botOpenID is the bot's own open_id used to filter mentions; if empty, any mention is treated as bot mention. +func extractFeishuInbound(event *larkim.P2MessageReceiveV1, botOpenID string) channel.InboundMessage { if event == nil || event.Event == nil || event.Event.Message == nil { return channel.InboundMessage{Channel: Type} } @@ -27,7 +28,7 @@ func extractFeishuInbound(event *larkim.P2MessageReceiveV1) channel.InboundMessa if message.Content != nil { _ = json.Unmarshal([]byte(*message.Content), &contentMap) } - isMentioned := hasFeishuMention(contentMap, message.Mentions) + isMentioned := isFeishuBotMentioned(contentMap, message.Mentions, botOpenID) if message.MessageType != nil { switch *message.MessageType { @@ -129,15 +130,37 @@ func extractFeishuInbound(event *larkim.P2MessageReceiveV1) channel.InboundMessa } } -func hasFeishuMention(contentMap map[string]any, mentions []*larkim.MentionEvent) bool { +// isFeishuBotMentioned checks whether the bot itself is mentioned in the message. +// When botOpenID is provided, only mentions matching the bot's open_id count. +// When botOpenID is empty (fallback), any mention is treated as a bot mention. +func isFeishuBotMentioned(contentMap map[string]any, mentions []*larkim.MentionEvent, botOpenID string) bool { + botOpenID = strings.TrimSpace(botOpenID) + if botOpenID == "" { + return hasAnyFeishuMention(contentMap, mentions) + } + for _, m := range mentions { + if m == nil || m.Id == nil || m.Id.OpenId == nil { + continue + } + if strings.TrimSpace(*m.Id.OpenId) == botOpenID { + return true + } + } + if matchFeishuContentMention(contentMap, botOpenID) { + return true + } + return false +} + +// hasAnyFeishuMention is the fallback when the bot's open_id is unknown. +func hasAnyFeishuMention(contentMap map[string]any, mentions []*larkim.MentionEvent) bool { if len(mentions) > 0 { return true } if len(contentMap) == 0 { return false } - raw, ok := contentMap["mentions"] - if ok { + if raw, ok := contentMap["mentions"]; ok { switch values := raw.(type) { case []any: if len(values) > 0 { @@ -147,10 +170,6 @@ func hasFeishuMention(contentMap map[string]any, mentions []*larkim.MentionEvent if len(values) > 0 { return true } - case map[string]any: - if len(values) > 0 { - return true - } } } if text, ok := contentMap["text"].(string); ok { @@ -162,6 +181,33 @@ func hasFeishuMention(contentMap map[string]any, mentions []*larkim.MentionEvent return hasFeishuAtTag(contentMap) } +// matchFeishuContentMention checks rich-text at tags for the bot's open_id. +func matchFeishuContentMention(raw any, botOpenID string) bool { + switch value := raw.(type) { + case map[string]any: + if tag, ok := value["tag"].(string); ok && strings.EqualFold(strings.TrimSpace(tag), "at") { + if uid, ok := value["user_id"].(string); ok && strings.TrimSpace(uid) == botOpenID { + return true + } + if uid, ok := value["open_id"].(string); ok && strings.TrimSpace(uid) == botOpenID { + return true + } + } + for _, child := range value { + if matchFeishuContentMention(child, botOpenID) { + return true + } + } + case []any: + for _, child := range value { + if matchFeishuContentMention(child, botOpenID) { + return true + } + } + } + return false +} + func hasFeishuAtTag(raw any) bool { switch value := raw.(type) { case map[string]any: diff --git a/internal/channel/identities/service.go b/internal/channel/identities/service.go index b5f0126d..463e16da 100644 --- a/internal/channel/identities/service.go +++ b/internal/channel/identities/service.go @@ -51,6 +51,7 @@ func (s *Service) Create(ctx context.Context, channel, channelSubjectID, display ChannelType: channel, ChannelSubjectID: channelSubjectID, DisplayName: toPgText(displayName), + AvatarUrl: pgtype.Text{}, Metadata: emptyMetadataBytes(), }) if err != nil { @@ -98,7 +99,8 @@ func (s *Service) Canonicalize(ctx context.Context, channelIdentityID string) (s } // ResolveByChannelIdentity looks up or creates a channel identity for (channel, channel_subject_id). -func (s *Service) ResolveByChannelIdentity(ctx context.Context, channel, channelSubjectID, displayName string) (ChannelIdentity, error) { +// Optional meta may contain avatar_url which is stored as a dedicated column. +func (s *Service) ResolveByChannelIdentity(ctx context.Context, channel, channelSubjectID, displayName string, meta map[string]any) (ChannelIdentity, error) { if s.queries == nil { return ChannelIdentity{}, fmt.Errorf("channel identity queries not configured") } @@ -108,11 +110,19 @@ func (s *Service) ResolveByChannelIdentity(ctx context.Context, channel, channel return ChannelIdentity{}, fmt.Errorf("channel and channel_subject_id are required") } + avatarURL := "" + if meta != nil { + if raw, ok := meta["avatar_url"]; ok { + avatarURL = strings.TrimSpace(fmt.Sprint(raw)) + } + } + row, err := s.queries.UpsertChannelIdentityByChannelSubject(ctx, sqlc.UpsertChannelIdentityByChannelSubjectParams{ UserID: pgtype.UUID{}, ChannelType: channel, ChannelSubjectID: channelSubjectID, DisplayName: toPgText(displayName), + AvatarUrl: toPgText(avatarURL), Metadata: emptyMetadataBytes(), }) if err != nil { @@ -135,11 +145,16 @@ func (s *Service) UpsertChannelIdentity(ctx context.Context, channel, channelSub if err != nil { return ChannelIdentity{}, err } + avatarURL := "" + if raw, ok := metadata["avatar_url"]; ok { + avatarURL = strings.TrimSpace(fmt.Sprint(raw)) + } row, err := s.queries.UpsertChannelIdentityByChannelSubject(ctx, sqlc.UpsertChannelIdentityByChannelSubjectParams{ UserID: pgtype.UUID{}, ChannelType: channel, ChannelSubjectID: channelSubjectID, DisplayName: toPgText(displayName), + AvatarUrl: toPgText(avatarURL), Metadata: metaBytes, }) if err != nil { @@ -258,6 +273,10 @@ func toChannelIdentity(row sqlc.ChannelIdentity) ChannelIdentity { if row.DisplayName.Valid { displayName = strings.TrimSpace(row.DisplayName.String) } + avatarURL := "" + if row.AvatarUrl.Valid { + avatarURL = strings.TrimSpace(row.AvatarUrl.String) + } userID := "" if row.UserID.Valid { userID = row.UserID.String() @@ -268,6 +287,7 @@ func toChannelIdentity(row sqlc.ChannelIdentity) ChannelIdentity { Channel: row.ChannelType, ChannelSubjectID: row.ChannelSubjectID, DisplayName: displayName, + AvatarURL: avatarURL, Metadata: metadata, CreatedAt: db.TimeFromPg(row.CreatedAt), UpdatedAt: db.TimeFromPg(row.UpdatedAt), diff --git a/internal/channel/identities/service_identity_integration_test.go b/internal/channel/identities/service_identity_integration_test.go index 9cda339a..a26125dc 100644 --- a/internal/channel/identities/service_identity_integration_test.go +++ b/internal/channel/identities/service_identity_integration_test.go @@ -45,11 +45,11 @@ func TestChannelIdentityResolveChannelIdentityStable(t *testing.T) { ctx := context.Background() externalID := fmt.Sprintf("stable_%d", time.Now().UnixNano()) - first, err := svc.ResolveByChannelIdentity(ctx, "feishu", externalID, "first") + first, err := svc.ResolveByChannelIdentity(ctx, "feishu", externalID, "first", nil) if err != nil { t.Fatalf("first resolve failed: %v", err) } - second, err := svc.ResolveByChannelIdentity(ctx, "feishu", externalID, "second") + second, err := svc.ResolveByChannelIdentity(ctx, "feishu", externalID, "second", nil) if err != nil { t.Fatalf("second resolve failed: %v", err) } @@ -63,7 +63,7 @@ func TestChannelIdentityLinkToUser(t *testing.T) { defer cleanup() ctx := context.Background() - channelIdentity, err := svc.ResolveByChannelIdentity(ctx, "telegram", fmt.Sprintf("link_%d", time.Now().UnixNano()), "tg") + channelIdentity, err := svc.ResolveByChannelIdentity(ctx, "telegram", fmt.Sprintf("link_%d", time.Now().UnixNano()), "tg", nil) if err != nil { t.Fatalf("resolve channelIdentity failed: %v", err) } diff --git a/internal/channel/identities/service_integration_test.go b/internal/channel/identities/service_integration_test.go index 6f1833d7..a5b89d71 100644 --- a/internal/channel/identities/service_integration_test.go +++ b/internal/channel/identities/service_integration_test.go @@ -50,11 +50,11 @@ func TestIntegrationResolveByChannelIdentityStability(t *testing.T) { ctx := context.Background() key := fmt.Sprintf("ext_%d", time.Now().UnixNano()) - first, err := svc.ResolveByChannelIdentity(ctx, "feishu", key, "first") + first, err := svc.ResolveByChannelIdentity(ctx, "feishu", key, "first", nil) if err != nil { t.Fatalf("first resolve failed: %v", err) } - second, err := svc.ResolveByChannelIdentity(ctx, "feishu", key, "second") + second, err := svc.ResolveByChannelIdentity(ctx, "feishu", key, "second", nil) if err != nil { t.Fatalf("second resolve failed: %v", err) } @@ -69,7 +69,7 @@ func TestIntegrationLinkChannelIdentityToUser(t *testing.T) { ctx := context.Background() key := fmt.Sprintf("bind_%d", time.Now().UnixNano()) - channelIdentity, err := svc.ResolveByChannelIdentity(ctx, "telegram", key, "tg-user") + channelIdentity, err := svc.ResolveByChannelIdentity(ctx, "telegram", key, "tg-user", nil) if err != nil { t.Fatalf("resolve channelIdentity failed: %v", err) } diff --git a/internal/channel/identities/types.go b/internal/channel/identities/types.go index cfc36d30..52c5728f 100644 --- a/internal/channel/identities/types.go +++ b/internal/channel/identities/types.go @@ -9,6 +9,7 @@ type ChannelIdentity struct { Channel string `json:"channel"` ChannelSubjectID string `json:"channel_subject_id"` DisplayName string `json:"display_name,omitempty"` + AvatarURL string `json:"avatar_url,omitempty"` Metadata map[string]any `json:"metadata,omitempty"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` diff --git a/internal/channel/registry.go b/internal/channel/registry.go index 9a672d59..df53e260 100644 --- a/internal/channel/registry.go +++ b/internal/channel/registry.go @@ -1,6 +1,7 @@ package channel import ( + "context" "fmt" "strings" "sync" @@ -235,6 +236,19 @@ func (r *Registry) GetProcessingStatusNotifier(channelType ChannelType) (Process return notifier, ok } +// DiscoverSelf calls the SelfDiscoverer for the given channel type if supported. +func (r *Registry) DiscoverSelf(ctx context.Context, channelType ChannelType, credentials map[string]any) (map[string]any, string, error) { + adapter, ok := r.Get(channelType) + if !ok { + return nil, "", fmt.Errorf("unsupported channel type: %s", channelType) + } + discoverer, ok := adapter.(SelfDiscoverer) + if !ok { + return nil, "", nil + } + return discoverer.DiscoverSelf(ctx, credentials) +} + // --- Dispatch methods (replace former global functions in config.go / target.go) --- // NormalizeConfig validates and normalizes a channel configuration map. diff --git a/internal/channel/service.go b/internal/channel/service.go index 5414af65..f3ba5c1c 100644 --- a/internal/channel/service.go +++ b/internal/channel/service.go @@ -53,6 +53,17 @@ func (s *Service) UpsertConfig(ctx context.Context, botID string, channelType Ch if selfIdentity == nil { selfIdentity = map[string]any{} } + externalIdentity := strings.TrimSpace(req.ExternalIdentity) + if discovered, extID, err := s.registry.DiscoverSelf(ctx, channelType, normalized); err == nil && discovered != nil { + for k, v := range discovered { + if _, exists := selfIdentity[k]; !exists { + selfIdentity[k] = v + } + } + if externalIdentity == "" && strings.TrimSpace(extID) != "" { + externalIdentity = strings.TrimSpace(extID) + } + } selfPayload, err := json.Marshal(selfIdentity) if err != nil { return ChannelConfig{}, err @@ -73,7 +84,6 @@ func (s *Service) UpsertConfig(ctx context.Context, botID string, channelType Ch if req.VerifiedAt != nil { verifiedAt = pgtype.Timestamptz{Time: req.VerifiedAt.UTC(), Valid: true} } - externalIdentity := strings.TrimSpace(req.ExternalIdentity) row, err := s.queries.UpsertBotChannelConfig(ctx, sqlc.UpsertBotChannelConfigParams{ BotID: botUUID, ChannelType: channelType.String(), diff --git a/internal/chat/assistant_output.go b/internal/chat/assistant_output.go deleted file mode 100644 index 235b121d..00000000 --- a/internal/chat/assistant_output.go +++ /dev/null @@ -1,36 +0,0 @@ -package conversation - -import "strings" - -// ExtractAssistantOutputs collects assistant-role outputs from a slice of ModelMessages. -func ExtractAssistantOutputs(messages []ModelMessage) []AssistantOutput { - if len(messages) == 0 { - return nil - } - outputs := make([]AssistantOutput, 0, len(messages)) - for _, msg := range messages { - if msg.Role != "assistant" { - continue - } - content := strings.TrimSpace(msg.TextContent()) - parts := filterContentParts(msg.ContentParts()) - if content == "" && len(parts) == 0 { - continue - } - outputs = append(outputs, AssistantOutput{Content: content, Parts: parts}) - } - return outputs -} - -func filterContentParts(parts []ContentPart) []ContentPart { - if len(parts) == 0 { - return nil - } - filtered := make([]ContentPart, 0, len(parts)) - for _, p := range parts { - if p.HasValue() { - filtered = append(filtered, p) - } - } - return filtered -} diff --git a/internal/chat/resolver.go b/internal/chat/resolver.go deleted file mode 100644 index 47f1d84e..00000000 --- a/internal/chat/resolver.go +++ /dev/null @@ -1,1175 +0,0 @@ -package conversation - -import ( - "bufio" - "bytes" - "context" - "encoding/json" - "fmt" - "io" - "log/slog" - "net/http" - "sort" - "strings" - "time" - - "github.com/jackc/pgx/v5/pgtype" - - "github.com/memohai/memoh/internal/db" - "github.com/memohai/memoh/internal/db/sqlc" - "github.com/memohai/memoh/internal/mcp" - "github.com/memohai/memoh/internal/memory" - "github.com/memohai/memoh/internal/models" - "github.com/memohai/memoh/internal/schedule" - "github.com/memohai/memoh/internal/settings" -) - -const ( - defaultMaxContextMinutes = 24 * 60 - memoryContextLimitPerScope = 4 - memoryContextMaxItems = 8 - memoryContextItemMaxChars = 220 - sharedMemoryNamespace = "bot" -) - -// SkillEntry represents a skill loaded from the container. -type SkillEntry struct { - Name string - Description string - Content string - Metadata map[string]any -} - -// SkillLoader loads skills for a given bot from its container. -type SkillLoader interface { - LoadSkills(ctx context.Context, botID string) ([]SkillEntry, error) -} - -// Resolver orchestrates chat with the agent gateway. -type Resolver struct { - modelsService *models.Service - queries *sqlc.Queries - memoryService *memory.Service - chatService *Service - settingsService *settings.Service - mcpService *mcp.ConnectionService - skillLoader SkillLoader - gatewayBaseURL string - timeout time.Duration - logger *slog.Logger - httpClient *http.Client - streamingClient *http.Client -} - -// NewResolver creates a Resolver that communicates with the agent gateway. -func NewResolver( - log *slog.Logger, - modelsService *models.Service, - queries *sqlc.Queries, - memoryService *memory.Service, - chatService *Service, - settingsService *settings.Service, - mcpService *mcp.ConnectionService, - gatewayBaseURL string, - timeout time.Duration, -) *Resolver { - if strings.TrimSpace(gatewayBaseURL) == "" { - gatewayBaseURL = "http://127.0.0.1:8081" - } - gatewayBaseURL = strings.TrimRight(gatewayBaseURL, "/") - if timeout <= 0 { - timeout = 60 * time.Second - } - return &Resolver{ - modelsService: modelsService, - queries: queries, - memoryService: memoryService, - chatService: chatService, - settingsService: settingsService, - mcpService: mcpService, - gatewayBaseURL: gatewayBaseURL, - timeout: timeout, - logger: log.With(slog.String("service", "chat_resolver")), - httpClient: &http.Client{Timeout: timeout}, - streamingClient: &http.Client{}, - } -} - -// SetSkillLoader sets the skill loader used to populate usable skills in gateway requests. -func (r *Resolver) SetSkillLoader(sl SkillLoader) { - r.skillLoader = sl -} - -// --- gateway payload --- - -type gatewayModelConfig struct { - ModelID string `json:"modelId"` - ClientType string `json:"clientType"` - Input []string `json:"input"` - APIKey string `json:"apiKey"` - BaseURL string `json:"baseUrl"` -} - -type gatewayIdentity struct { - BotID string `json:"botId"` - ContainerID string `json:"containerId"` - ChannelIdentityID string `json:"channelIdentityId"` - DisplayName string `json:"displayName"` - CurrentPlatform string `json:"currentPlatform,omitempty"` - ReplyTarget string `json:"replyTarget,omitempty"` - SessionToken string `json:"sessionToken,omitempty"` -} - -type gatewaySkill struct { - Name string `json:"name"` - Description string `json:"description"` - Content string `json:"content"` - Metadata map[string]any `json:"metadata,omitempty"` -} - -type gatewayRequest struct { - Model gatewayModelConfig `json:"model"` - ActiveContextTime int `json:"activeContextTime"` - Channels []string `json:"channels"` - CurrentChannel string `json:"currentChannel"` - AllowedActions []string `json:"allowedActions,omitempty"` - Messages []ModelMessage `json:"messages"` - Skills []string `json:"skills"` - UsableSkills []gatewaySkill `json:"usableSkills"` - Query string `json:"query"` - Identity gatewayIdentity `json:"identity"` - Attachments []any `json:"attachments"` -} - -type gatewayResponse struct { - Messages []ModelMessage `json:"messages"` - Skills []string `json:"skills"` -} - -// gatewaySchedule matches the agent gateway ScheduleModel for /chat/trigger-schedule. -type gatewaySchedule struct { - ID string `json:"id"` - Name string `json:"name"` - Description string `json:"description"` - Pattern string `json:"pattern"` - MaxCalls *int `json:"maxCalls,omitempty"` - Command string `json:"command"` -} - -// triggerScheduleRequest is the payload for POST /chat/trigger-schedule. -type triggerScheduleRequest struct { - Model gatewayModelConfig `json:"model"` - ActiveContextTime int `json:"activeContextTime"` - Channels []string `json:"channels"` - CurrentChannel string `json:"currentChannel"` - AllowedActions []string `json:"allowedActions,omitempty"` - Messages []ModelMessage `json:"messages"` - Skills []string `json:"skills"` - UsableSkills []gatewaySkill `json:"usableSkills"` - Identity gatewayIdentity `json:"identity"` - Attachments []any `json:"attachments"` - Schedule gatewaySchedule `json:"schedule"` -} - -// --- resolved context (shared by Chat / StreamChat / TriggerSchedule) --- - -type resolvedContext struct { - payload gatewayRequest - model models.GetResponse - provider sqlc.LlmProvider -} - -func (r *Resolver) resolve(ctx context.Context, req ChatRequest) (resolvedContext, error) { - if strings.TrimSpace(req.Query) == "" { - return resolvedContext{}, fmt.Errorf("query is required") - } - if strings.TrimSpace(req.BotID) == "" { - return resolvedContext{}, fmt.Errorf("bot id is required") - } - if strings.TrimSpace(req.ChatID) == "" { - return resolvedContext{}, fmt.Errorf("chat id is required") - } - - skipHistory := req.MaxContextLoadTime < 0 - - botSettings, err := r.loadBotSettings(ctx, req.BotID) - if err != nil { - return resolvedContext{}, err - } - - // Check chat-level model override. - var chatSettings Settings - if r.chatService != nil { - chatSettings, err = r.chatService.GetSettings(ctx, req.ChatID) - if err != nil { - return resolvedContext{}, err - } - } - - userSettings, err := r.loadUserSettings(ctx, req.UserID) - if err != nil { - return resolvedContext{}, err - } - chatModel, provider, err := r.selectChatModel(ctx, req, botSettings, userSettings, chatSettings) - if err != nil { - return resolvedContext{}, err - } - clientType, err := normalizeClientType(provider.ClientType) - if err != nil { - return resolvedContext{}, err - } - maxCtx := coalescePositiveInt(req.MaxContextLoadTime, botSettings.MaxContextLoadTime, defaultMaxContextMinutes) - - var messages []ModelMessage - if !skipHistory && r.chatService != nil { - messages, err = r.loadMessages(ctx, req.ChatID, maxCtx) - if err != nil { - return resolvedContext{}, err - } - } - if memoryMsg := r.loadMemoryContextMessage(ctx, req); memoryMsg != nil { - messages = append(messages, *memoryMsg) - } - messages = append(messages, req.Messages...) - messages = sanitizeMessages(messages) - skills := dedup(req.Skills) - containerID := r.resolveContainerID(ctx, req.BotID, req.ContainerID) - - var usableSkills []gatewaySkill - if r.skillLoader != nil { - entries, err := r.skillLoader.LoadSkills(ctx, req.BotID) - if err != nil { - r.logger.Warn("failed to load usable skills", slog.String("bot_id", req.BotID), slog.Any("error", err)) - } else { - usableSkills = make([]gatewaySkill, 0, len(entries)) - for _, e := range entries { - usableSkills = append(usableSkills, gatewaySkill{ - Name: e.Name, - Description: e.Description, - Content: e.Content, - Metadata: e.Metadata, - }) - } - } - } - if usableSkills == nil { - usableSkills = []gatewaySkill{} - } - - payload := gatewayRequest{ - Model: gatewayModelConfig{ - ModelID: chatModel.ModelID, - ClientType: clientType, - Input: chatModel.Input, - APIKey: provider.ApiKey, - BaseURL: provider.BaseUrl, - }, - ActiveContextTime: maxCtx, - Channels: nonNilStrings(req.Channels), - CurrentChannel: req.CurrentChannel, - AllowedActions: req.AllowedActions, - Messages: nonNilModelMessages(messages), - Skills: nonNilStrings(skills), - UsableSkills: usableSkills, - Query: req.Query, - Identity: gatewayIdentity{ - BotID: req.BotID, - ContainerID: containerID, - ChannelIdentityID: firstNonEmpty(req.SourceChannelIdentityID, req.UserID), - DisplayName: firstNonEmpty(req.DisplayName, "User"), - CurrentPlatform: req.CurrentChannel, - ReplyTarget: "", - SessionToken: req.ChatToken, - }, - Attachments: []any{}, - } - - return resolvedContext{payload: payload, model: chatModel, provider: provider}, nil -} - -// --- Chat --- - -// Chat sends a synchronous chat request to the agent gateway and stores the result. -func (r *Resolver) Chat(ctx context.Context, req ChatRequest) (ChatResponse, error) { - rc, err := r.resolve(ctx, req) - if err != nil { - return ChatResponse{}, err - } - resp, err := r.postChat(ctx, rc.payload, req.Token) - if err != nil { - return ChatResponse{}, err - } - if err := r.storeRound(ctx, req, resp.Messages); err != nil { - return ChatResponse{}, err - } - return ChatResponse{ - Messages: resp.Messages, - Skills: resp.Skills, - Model: rc.model.ModelID, - Provider: rc.provider.ClientType, - }, nil -} - -// --- TriggerSchedule --- - -// TriggerSchedule executes a scheduled command through the agent gateway trigger-schedule endpoint. -func (r *Resolver) TriggerSchedule(ctx context.Context, botID string, payload schedule.TriggerPayload, token string) error { - if strings.TrimSpace(botID) == "" { - return fmt.Errorf("bot id is required") - } - if strings.TrimSpace(payload.Command) == "" { - return fmt.Errorf("schedule command is required") - } - - chatID := payload.ChatID - if strings.TrimSpace(chatID) == "" { - chatID = "schedule-" + payload.ID - } - req := ChatRequest{ - BotID: botID, - ChatID: chatID, - Query: payload.Command, - UserID: payload.OwnerUserID, - Token: token, - } - rc, err := r.resolve(ctx, req) - if err != nil { - return err - } - - triggerReq := triggerScheduleRequest{ - Model: rc.payload.Model, - ActiveContextTime: rc.payload.ActiveContextTime, - Channels: rc.payload.Channels, - CurrentChannel: rc.payload.CurrentChannel, - AllowedActions: rc.payload.AllowedActions, - Messages: rc.payload.Messages, - Skills: rc.payload.Skills, - UsableSkills: rc.payload.UsableSkills, - Identity: gatewayIdentity{ - BotID: rc.payload.Identity.BotID, - ContainerID: rc.payload.Identity.ContainerID, - ChannelIdentityID: strings.TrimSpace(payload.OwnerUserID), - DisplayName: "Scheduler", - }, - Attachments: rc.payload.Attachments, - Schedule: gatewaySchedule{ - ID: payload.ID, - Name: payload.Name, - Description: payload.Description, - Pattern: payload.Pattern, - MaxCalls: payload.MaxCalls, - Command: payload.Command, - }, - } - - resp, err := r.postTriggerSchedule(ctx, triggerReq, token) - if err != nil { - return err - } - return r.storeRound(ctx, req, resp.Messages) -} - -// --- StreamChat --- - -// StreamChat sends a streaming chat request to the agent gateway. -func (r *Resolver) StreamChat(ctx context.Context, req ChatRequest) (<-chan StreamChunk, <-chan error) { - chunkCh := make(chan StreamChunk) - errCh := make(chan error, 1) - r.logger.Info("gateway stream start", - slog.String("bot_id", req.BotID), - slog.String("chat_id", req.ChatID), - ) - - go func() { - defer close(chunkCh) - defer close(errCh) - - streamReq := req - rc, err := r.resolve(ctx, streamReq) - if err != nil { - r.logger.Error("gateway stream resolve failed", - slog.String("bot_id", streamReq.BotID), - slog.String("chat_id", streamReq.ChatID), - slog.Any("error", err), - ) - errCh <- err - return - } - if err := r.persistUserMessage(ctx, streamReq); err != nil { - r.logger.Error("gateway stream persist user message failed", - slog.String("bot_id", streamReq.BotID), - slog.String("chat_id", streamReq.ChatID), - slog.Any("error", err), - ) - errCh <- err - return - } - streamReq.UserMessagePersisted = true - if err := r.streamChat(ctx, rc.payload, streamReq, chunkCh); err != nil { - r.logger.Error("gateway stream request failed", - slog.String("bot_id", streamReq.BotID), - slog.String("chat_id", streamReq.ChatID), - slog.Any("error", err), - ) - errCh <- err - } - }() - return chunkCh, errCh -} - -// --- HTTP helpers --- - -func (r *Resolver) postChat(ctx context.Context, payload gatewayRequest, token string) (gatewayResponse, error) { - body, err := json.Marshal(payload) - if err != nil { - return gatewayResponse{}, err - } - url := r.gatewayBaseURL + "/chat/" - r.logger.Info("gateway request", slog.String("url", url), slog.String("body_prefix", truncate(string(body), 200))) - - httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) - if err != nil { - return gatewayResponse{}, err - } - httpReq.Header.Set("Content-Type", "application/json") - if strings.TrimSpace(token) != "" { - httpReq.Header.Set("Authorization", token) - } - - resp, err := r.httpClient.Do(httpReq) - if err != nil { - return gatewayResponse{}, err - } - defer resp.Body.Close() - - respBody, err := io.ReadAll(resp.Body) - if err != nil { - return gatewayResponse{}, err - } - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - r.logger.Error("gateway error", slog.String("url", url), slog.Int("status", resp.StatusCode), slog.String("body_prefix", truncate(string(respBody), 300))) - return gatewayResponse{}, fmt.Errorf("agent gateway error: %s", strings.TrimSpace(string(respBody))) - } - - var parsed gatewayResponse - if err := json.Unmarshal(respBody, &parsed); err != nil { - r.logger.Error("gateway response parse failed", slog.String("body_prefix", truncate(string(respBody), 300)), slog.Any("error", err)) - return gatewayResponse{}, fmt.Errorf("failed to parse gateway response: %w", err) - } - return parsed, nil -} - -// postTriggerSchedule sends a trigger-schedule request to the agent gateway. -func (r *Resolver) postTriggerSchedule(ctx context.Context, payload triggerScheduleRequest, token string) (gatewayResponse, error) { - body, err := json.Marshal(payload) - if err != nil { - return gatewayResponse{}, err - } - url := r.gatewayBaseURL + "/chat/trigger-schedule" - r.logger.Info("gateway trigger-schedule request", slog.String("url", url), slog.String("schedule_id", payload.Schedule.ID)) - - httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) - if err != nil { - return gatewayResponse{}, err - } - httpReq.Header.Set("Content-Type", "application/json") - if strings.TrimSpace(token) != "" { - httpReq.Header.Set("Authorization", token) - } - - resp, err := r.httpClient.Do(httpReq) - if err != nil { - return gatewayResponse{}, err - } - defer resp.Body.Close() - - respBody, err := io.ReadAll(resp.Body) - if err != nil { - return gatewayResponse{}, err - } - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - r.logger.Error("gateway trigger-schedule error", slog.String("url", url), slog.Int("status", resp.StatusCode), slog.String("body_prefix", truncate(string(respBody), 300))) - return gatewayResponse{}, fmt.Errorf("agent gateway error: %s", strings.TrimSpace(string(respBody))) - } - - var parsed gatewayResponse - if err := json.Unmarshal(respBody, &parsed); err != nil { - r.logger.Error("gateway trigger-schedule response parse failed", slog.String("body_prefix", truncate(string(respBody), 300)), slog.Any("error", err)) - return gatewayResponse{}, fmt.Errorf("failed to parse gateway response: %w", err) - } - return parsed, nil -} - -func (r *Resolver) streamChat(ctx context.Context, payload gatewayRequest, req ChatRequest, chunkCh chan<- StreamChunk) error { - body, err := json.Marshal(payload) - if err != nil { - return err - } - url := r.gatewayBaseURL + "/chat/stream" - r.logger.Info("gateway stream request", slog.String("url", url), slog.String("body_prefix", truncate(string(body), 200))) - httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) - if err != nil { - return err - } - httpReq.Header.Set("Content-Type", "application/json") - httpReq.Header.Set("Accept", "text/event-stream") - if strings.TrimSpace(req.Token) != "" { - httpReq.Header.Set("Authorization", req.Token) - } - - resp, err := r.streamingClient.Do(httpReq) - if err != nil { - r.logger.Error("gateway stream connect failed", slog.String("url", url), slog.Any("error", err)) - return err - } - defer resp.Body.Close() - - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - errBody, _ := io.ReadAll(resp.Body) - r.logger.Error("gateway stream error", slog.String("url", url), slog.Int("status", resp.StatusCode), slog.String("body_prefix", truncate(string(errBody), 300))) - return fmt.Errorf("agent gateway error: %s", strings.TrimSpace(string(errBody))) - } - - scanner := bufio.NewScanner(resp.Body) - scanner.Buffer(make([]byte, 0, 64*1024), 2*1024*1024) - - currentEvent := "" - stored := false - for scanner.Scan() { - line := strings.TrimSpace(scanner.Text()) - if line == "" { - continue - } - if strings.HasPrefix(line, "event:") { - currentEvent = strings.TrimSpace(strings.TrimPrefix(line, "event:")) - continue - } - if !strings.HasPrefix(line, "data:") { - continue - } - data := strings.TrimSpace(strings.TrimPrefix(line, "data:")) - if data == "" || data == "[DONE]" { - continue - } - chunkCh <- StreamChunk([]byte(data)) - - if stored { - continue - } - if handled, storeErr := r.tryStoreStream(ctx, req, currentEvent, data); storeErr != nil { - return storeErr - } else if handled { - stored = true - } - } - return scanner.Err() -} - -// tryStoreStream attempts to extract final messages from a stream event and persist them. -func (r *Resolver) tryStoreStream(ctx context.Context, req ChatRequest, eventType, data string) (bool, error) { - // event: done + data: {messages: [...]} - if eventType == "done" { - var resp gatewayResponse - if err := json.Unmarshal([]byte(data), &resp); err == nil && len(resp.Messages) > 0 { - return true, r.storeRound(ctx, req, resp.Messages) - } - } - - // data: {"type":"text_delta"|"agent_end"|"done", ...} - var envelope struct { - Type string `json:"type"` - Data json.RawMessage `json:"data"` - Messages []ModelMessage `json:"messages"` - Skills []string `json:"skills"` - } - if err := json.Unmarshal([]byte(data), &envelope); err == nil { - if (envelope.Type == "agent_end" || envelope.Type == "done") && len(envelope.Messages) > 0 { - return true, r.storeRound(ctx, req, envelope.Messages) - } - if envelope.Type == "done" && len(envelope.Data) > 0 { - var resp gatewayResponse - if err := json.Unmarshal(envelope.Data, &resp); err == nil && len(resp.Messages) > 0 { - return true, r.storeRound(ctx, req, resp.Messages) - } - } - } - - // fallback: data: {messages: [...]} - var resp gatewayResponse - if err := json.Unmarshal([]byte(data), &resp); err == nil && len(resp.Messages) > 0 { - return true, r.storeRound(ctx, req, resp.Messages) - } - return false, nil -} - -// --- container resolution --- - -func (r *Resolver) resolveContainerID(ctx context.Context, botID, explicit string) string { - if strings.TrimSpace(explicit) != "" { - return explicit - } - if r.queries != nil { - pgBotID, err := parseUUID(botID) - if err == nil { - row, err := r.queries.GetContainerByBotID(ctx, pgBotID) - if err == nil && strings.TrimSpace(row.ContainerID) != "" { - return row.ContainerID - } - } - } - return "mcp-" + botID -} - -// --- message loading --- - -func (r *Resolver) loadMessages(ctx context.Context, chatID string, maxContextMinutes int) ([]ModelMessage, error) { - since := time.Now().UTC().Add(-time.Duration(maxContextMinutes) * time.Minute) - msgs, err := r.chatService.ListMessagesSince(ctx, chatID, since) - if err != nil { - return nil, err - } - var result []ModelMessage - for _, m := range msgs { - var mm ModelMessage - if err := json.Unmarshal(m.Content, &mm); err != nil { - // Fallback: treat content as text string. - mm = ModelMessage{Role: m.Role, Content: m.Content} - } else { - mm.Role = m.Role - } - result = append(result, mm) - } - return result, nil -} - -type memoryContextItem struct { - Namespace string - Item memory.MemoryItem -} - -func (r *Resolver) loadMemoryContextMessage(ctx context.Context, req ChatRequest) *ModelMessage { - if r.memoryService == nil { - return nil - } - if strings.TrimSpace(req.Query) == "" || strings.TrimSpace(req.BotID) == "" || strings.TrimSpace(req.ChatID) == "" { - return nil - } - - results := make([]memoryContextItem, 0, memoryContextLimitPerScope) - seen := map[string]struct{}{} - resp, err := r.memoryService.Search(ctx, memory.SearchRequest{ - Query: req.Query, - BotID: req.BotID, - Limit: memoryContextLimitPerScope, - Filters: map[string]any{ - "namespace": sharedMemoryNamespace, - "scopeId": req.BotID, - "botId": req.BotID, - }, - }) - if err != nil { - r.logger.Warn("memory search for context failed", - slog.String("namespace", sharedMemoryNamespace), - slog.Any("error", err), - ) - return nil - } - for _, item := range resp.Results { - key := strings.TrimSpace(item.ID) - if key == "" { - key = sharedMemoryNamespace + ":" + strings.TrimSpace(item.Memory) - } - if key == "" { - continue - } - if _, ok := seen[key]; ok { - continue - } - seen[key] = struct{}{} - results = append(results, memoryContextItem{Namespace: sharedMemoryNamespace, Item: item}) - } - if len(results) == 0 { - return nil - } - - sort.Slice(results, func(i, j int) bool { - return results[i].Item.Score > results[j].Item.Score - }) - if len(results) > memoryContextMaxItems { - results = results[:memoryContextMaxItems] - } - - var sb strings.Builder - sb.WriteString("Relevant memory context (use when helpful):\n") - for _, entry := range results { - text := strings.TrimSpace(entry.Item.Memory) - if text == "" { - continue - } - sb.WriteString("- [") - sb.WriteString(entry.Namespace) - sb.WriteString("] ") - sb.WriteString(truncateMemorySnippet(text, memoryContextItemMaxChars)) - sb.WriteString("\n") - } - payload := strings.TrimSpace(sb.String()) - if payload == "" { - return nil - } - msg := ModelMessage{ - Role: "system", - Content: NewTextContent(payload), - } - return &msg -} - -// --- store helpers --- - -func (r *Resolver) persistUserMessage(ctx context.Context, req ChatRequest) error { - if r.chatService == nil { - return nil - } - if strings.TrimSpace(req.BotID) == "" { - return fmt.Errorf("bot id is required for persistence") - } - text := strings.TrimSpace(req.Query) - if text == "" { - return nil - } - - message := ModelMessage{ - Role: "user", - Content: NewTextContent(text), - } - content, err := json.Marshal(message) - if err != nil { - return err - } - senderChannelIdentityID, senderUserID := r.resolvePersistSenderIDs(ctx, req) - _, err = r.chatService.PersistMessage( - ctx, - req.BotID, - req.RouteID, - senderChannelIdentityID, - senderUserID, - req.CurrentChannel, - req.ExternalMessageID, - "", - "user", - content, - buildRouteMetadata(req), - ) - return err -} - -func (r *Resolver) storeRound(ctx context.Context, req ChatRequest, messages []ModelMessage) error { - // Add user query as the first message if not already present in the round. - // This ensures the user's prompt is persisted alongside the assistant's response. - fullRound := make([]ModelMessage, 0, len(messages)+1) - hasUserQuery := false - for _, m := range messages { - if m.Role == "user" && m.TextContent() == req.Query { - hasUserQuery = true - break - } - } - if !req.UserMessagePersisted && !hasUserQuery && strings.TrimSpace(req.Query) != "" { - fullRound = append(fullRound, ModelMessage{ - Role: "user", - Content: NewTextContent(req.Query), - }) - } - for _, m := range messages { - if req.UserMessagePersisted && m.Role == "user" && strings.TrimSpace(m.TextContent()) == strings.TrimSpace(req.Query) { - // User message was already persisted before streaming; skip duplicate copy in round payload. - continue - } - fullRound = append(fullRound, m) - } - if len(fullRound) == 0 { - return nil - } - - r.storeMessages(ctx, req, fullRound) - r.storeMemory(ctx, req.BotID, fullRound) - return nil -} - -func (r *Resolver) storeMessages(ctx context.Context, req ChatRequest, messages []ModelMessage) { - if r.chatService == nil { - return - } - if strings.TrimSpace(req.BotID) == "" { - return - } - meta := buildRouteMetadata(req) - senderChannelIdentityID, senderUserID := r.resolvePersistSenderIDs(ctx, req) - for _, msg := range messages { - content, err := json.Marshal(msg) - if err != nil { - continue - } - messageSenderChannelIdentityID := "" - messageSenderUserID := "" - externalMessageID := "" - sourceReplyToMessageID := "" - if msg.Role == "user" { - messageSenderChannelIdentityID = senderChannelIdentityID - messageSenderUserID = senderUserID - externalMessageID = req.ExternalMessageID - } else if strings.TrimSpace(req.ExternalMessageID) != "" { - // Assistant/tool/system outputs are linked to the inbound source message for cross-channel reply threading. - sourceReplyToMessageID = req.ExternalMessageID - } - if _, err := r.chatService.PersistMessage( - ctx, - req.BotID, - req.RouteID, - messageSenderChannelIdentityID, - messageSenderUserID, - req.CurrentChannel, - externalMessageID, - sourceReplyToMessageID, - msg.Role, - content, - meta, - ); err != nil { - r.logger.Warn("persist message failed", slog.Any("error", err)) - } - } -} - -func buildRouteMetadata(req ChatRequest) map[string]any { - if strings.TrimSpace(req.RouteID) == "" && strings.TrimSpace(req.CurrentChannel) == "" { - return nil - } - meta := map[string]any{} - if strings.TrimSpace(req.RouteID) != "" { - meta["route_id"] = req.RouteID - } - if strings.TrimSpace(req.CurrentChannel) != "" { - meta["platform"] = req.CurrentChannel - } - return meta -} - -func (r *Resolver) resolvePersistSenderIDs(ctx context.Context, req ChatRequest) (string, string) { - channelIdentityID := strings.TrimSpace(req.SourceChannelIdentityID) - userID := strings.TrimSpace(req.UserID) - - channelIdentityValid := r.isExistingChannelIdentityID(ctx, channelIdentityID) - userAsUserValid := r.isExistingUserID(ctx, userID) - userAsChannelIdentityValid := r.isExistingChannelIdentityID(ctx, userID) - - senderChannelIdentityID := "" - switch { - case channelIdentityValid: - senderChannelIdentityID = channelIdentityID - case userAsChannelIdentityValid && !userAsUserValid: - // Some flows may carry channel_identity_id in req.UserID. - senderChannelIdentityID = userID - } - - senderUserID := "" - if userAsUserValid { - senderUserID = userID - } - if senderUserID == "" && senderChannelIdentityID != "" { - if linked := r.linkedUserIDFromChannelIdentity(ctx, senderChannelIdentityID); linked != "" { - senderUserID = linked - } - } - return senderChannelIdentityID, senderUserID -} - -func (r *Resolver) isExistingChannelIdentityID(ctx context.Context, id string) bool { - if r.queries == nil { - return false - } - pgID, err := parseUUID(id) - if err != nil { - return false - } - _, err = r.queries.GetChannelIdentityByID(ctx, pgID) - return err == nil -} - -func (r *Resolver) isExistingUserID(ctx context.Context, id string) bool { - if r.queries == nil { - return false - } - pgID, err := parseUUID(id) - if err != nil { - return false - } - _, err = r.queries.GetUserByID(ctx, pgID) - return err == nil -} - -func (r *Resolver) linkedUserIDFromChannelIdentity(ctx context.Context, channelIdentityID string) string { - if r.queries == nil { - return "" - } - pgID, err := parseUUID(channelIdentityID) - if err != nil { - return "" - } - row, err := r.queries.GetChannelIdentityByID(ctx, pgID) - if err != nil || !row.UserID.Valid { - return "" - } - return row.UserID.String() -} - -func (r *Resolver) storeMemory(ctx context.Context, botID string, messages []ModelMessage) { - if r.memoryService == nil { - return - } - if strings.TrimSpace(botID) == "" { - return - } - memMsgs := make([]memory.Message, 0, len(messages)) - for _, msg := range messages { - text := strings.TrimSpace(msg.TextContent()) - if text == "" { - continue - } - role := msg.Role - if strings.TrimSpace(role) == "" { - role = "assistant" - } - memMsgs = append(memMsgs, memory.Message{Role: role, Content: text}) - } - if len(memMsgs) == 0 { - return - } - r.addMemory(ctx, botID, memMsgs, sharedMemoryNamespace, botID) -} - -func (r *Resolver) addMemory(ctx context.Context, botID string, msgs []memory.Message, namespace, scopeID string) { - filters := map[string]any{ - "namespace": namespace, - "scopeId": scopeID, - "botId": botID, - } - if _, err := r.memoryService.Add(ctx, memory.AddRequest{ - Messages: msgs, - BotID: botID, - Filters: filters, - }); err != nil { - r.logger.Warn("store memory failed", - slog.String("namespace", namespace), - slog.String("scope_id", scopeID), - slog.Any("error", err), - ) - } -} - -// --- model selection --- - -func (r *Resolver) selectChatModel(ctx context.Context, req ChatRequest, botSettings settings.Settings, us resolvedUserSettings, cs Settings) (models.GetResponse, sqlc.LlmProvider, error) { - if r.modelsService == nil { - return models.GetResponse{}, sqlc.LlmProvider{}, fmt.Errorf("models service not configured") - } - modelID := strings.TrimSpace(req.Model) - providerFilter := strings.TrimSpace(req.Provider) - - // Priority: request model > chat settings > bot settings > user settings. - if modelID == "" && providerFilter == "" { - if value := strings.TrimSpace(cs.ModelID); value != "" { - modelID = value - } else if value := strings.TrimSpace(botSettings.ChatModelID); value != "" { - modelID = value - } else if value := strings.TrimSpace(us.ChatModelID); value != "" { - modelID = value - } - } - - if modelID == "" { - return models.GetResponse{}, sqlc.LlmProvider{}, fmt.Errorf("chat model not configured: specify model in request or bot settings") - } - - if providerFilter == "" { - return r.fetchChatModel(ctx, modelID) - } - - candidates, err := r.listCandidates(ctx, providerFilter) - if err != nil { - return models.GetResponse{}, sqlc.LlmProvider{}, err - } - for _, m := range candidates { - if m.ModelID == modelID { - prov, err := models.FetchProviderByID(ctx, r.queries, m.LlmProviderID) - if err != nil { - return models.GetResponse{}, sqlc.LlmProvider{}, err - } - return m, prov, nil - } - } - return models.GetResponse{}, sqlc.LlmProvider{}, fmt.Errorf("chat model %q not found for provider %q", modelID, providerFilter) -} - -func (r *Resolver) fetchChatModel(ctx context.Context, modelID string) (models.GetResponse, sqlc.LlmProvider, error) { - model, err := r.modelsService.GetByModelID(ctx, modelID) - if err != nil { - return models.GetResponse{}, sqlc.LlmProvider{}, err - } - if model.Type != models.ModelTypeChat { - return models.GetResponse{}, sqlc.LlmProvider{}, fmt.Errorf("model is not a chat model") - } - prov, err := models.FetchProviderByID(ctx, r.queries, model.LlmProviderID) - if err != nil { - return models.GetResponse{}, sqlc.LlmProvider{}, err - } - return model, prov, nil -} - -func (r *Resolver) listCandidates(ctx context.Context, providerFilter string) ([]models.GetResponse, error) { - var all []models.GetResponse - var err error - if providerFilter != "" { - all, err = r.modelsService.ListByClientType(ctx, models.ClientType(providerFilter)) - } else { - all, err = r.modelsService.ListByType(ctx, models.ModelTypeChat) - } - if err != nil { - return nil, err - } - filtered := make([]models.GetResponse, 0, len(all)) - for _, m := range all { - if m.Type == models.ModelTypeChat { - filtered = append(filtered, m) - } - } - return filtered, nil -} - -// --- settings --- - -type resolvedUserSettings struct { - ChatModelID string -} - -func (r *Resolver) loadUserSettings(ctx context.Context, userID string) (resolvedUserSettings, error) { - if r.settingsService == nil || strings.TrimSpace(userID) == "" { - return resolvedUserSettings{}, nil - } - s, err := r.settingsService.Get(ctx, userID) - if err != nil { - return resolvedUserSettings{}, err - } - return resolvedUserSettings{ - ChatModelID: strings.TrimSpace(s.ChatModelID), - }, nil -} - -func (r *Resolver) loadBotSettings(ctx context.Context, botID string) (settings.Settings, error) { - if r.settingsService == nil { - return settings.Settings{ - MaxContextLoadTime: settings.DefaultMaxContextLoadTime, - Language: settings.DefaultLanguage, - }, nil - } - return r.settingsService.GetBot(ctx, botID) -} - -// --- utility --- - -func normalizeClientType(clientType string) (string, error) { - switch strings.ToLower(strings.TrimSpace(clientType)) { - case "openai", "openai-compat": - return "openai", nil - case "anthropic": - return "anthropic", nil - case "google": - return "google", nil - default: - return "", fmt.Errorf("unsupported agent gateway client type: %s", clientType) - } -} - -func sanitizeMessages(messages []ModelMessage) []ModelMessage { - cleaned := make([]ModelMessage, 0, len(messages)) - for _, msg := range messages { - if strings.TrimSpace(msg.Role) == "" { - continue - } - if !msg.HasContent() && strings.TrimSpace(msg.ToolCallID) == "" { - continue - } - cleaned = append(cleaned, msg) - } - return cleaned -} - -func dedup(items []string) []string { - seen := make(map[string]struct{}, len(items)) - result := make([]string, 0, len(items)) - for _, s := range items { - trimmed := strings.TrimSpace(s) - if trimmed == "" { - continue - } - if _, ok := seen[trimmed]; ok { - continue - } - seen[trimmed] = struct{}{} - result = append(result, trimmed) - } - return result -} - -func firstNonEmpty(values ...string) string { - for _, v := range values { - if strings.TrimSpace(v) != "" { - return v - } - } - return "" -} - -func coalescePositiveInt(values ...int) int { - for _, v := range values { - if v > 0 { - return v - } - } - return defaultMaxContextMinutes -} - -func nonNilStrings(s []string) []string { - if s == nil { - return []string{} - } - return s -} - -func nonNilModelMessages(m []ModelMessage) []ModelMessage { - if m == nil { - return []ModelMessage{} - } - return m -} - -func truncate(s string, n int) string { - if len(s) <= n { - return s - } - return s[:n] + "..." -} - -func truncateMemorySnippet(s string, n int) string { - trimmed := strings.TrimSpace(s) - if len(trimmed) <= n { - return trimmed - } - return strings.TrimSpace(trimmed[:n]) + "..." -} - -func parseUUID(id string) (pgtype.UUID, error) { - if strings.TrimSpace(id) == "" { - return pgtype.UUID{}, fmt.Errorf("empty id") - } - return db.ParseUUID(id) -} diff --git a/internal/chat/resolver_memory_context_test.go b/internal/chat/resolver_memory_context_test.go deleted file mode 100644 index 48c9eb39..00000000 --- a/internal/chat/resolver_memory_context_test.go +++ /dev/null @@ -1,55 +0,0 @@ -package conversation - -import ( - "context" - "log/slog" - "strings" - "testing" - - "github.com/memohai/memoh/internal/memory" -) - -func TestLoadMemoryContextMessage_NoMemoryService(t *testing.T) { - resolver := &Resolver{ - memoryService: nil, - logger: slog.Default(), - } - msg := resolver.loadMemoryContextMessage(context.Background(), ChatRequest{ - Query: "hello", - BotID: "bot-1", - ChatID: "chat-1", - }) - if msg != nil { - t.Fatalf("expected nil message when memory service is nil") - } -} - -func TestLoadMemoryContextMessage_SearchFailureFallback(t *testing.T) { - resolver := &Resolver{ - memoryService: &memory.Service{}, - logger: slog.Default(), - } - msg := resolver.loadMemoryContextMessage(context.Background(), ChatRequest{ - Query: "hello", - BotID: "bot-1", - ChatID: "chat-1", - UserID: "user-1", - }) - if msg != nil { - t.Fatalf("expected nil message when memory search cannot return results") - } -} - -func TestTruncateMemorySnippet(t *testing.T) { - longText := strings.Repeat("a", 20) + " " - got := truncateMemorySnippet(longText, 10) - if got != strings.Repeat("a", 10)+"..." { - t.Fatalf("unexpected truncated value: %q", got) - } - - shortText := " short " - got = truncateMemorySnippet(shortText, 10) - if got != "short" { - t.Fatalf("unexpected trimmed short value: %q", got) - } -} diff --git a/internal/chat/resolver_test.go b/internal/chat/resolver_test.go deleted file mode 100644 index e866ba00..00000000 --- a/internal/chat/resolver_test.go +++ /dev/null @@ -1,158 +0,0 @@ -package conversation - -import ( - "context" - "encoding/json" - "io" - "log/slog" - "net/http" - "net/http/httptest" - "testing" - "time" -) - -func TestPostTriggerSchedule_Endpoint(t *testing.T) { - var capturedPath string - var capturedBody []byte - var capturedAuth string - - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - capturedPath = r.URL.Path - capturedAuth = r.Header.Get("Authorization") - capturedBody, _ = io.ReadAll(r.Body) - resp := gatewayResponse{ - Messages: []ModelMessage{{Role: "assistant", Content: NewTextContent("ok")}}, - } - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(resp) - })) - defer srv.Close() - - resolver := &Resolver{ - gatewayBaseURL: srv.URL, - httpClient: &http.Client{Timeout: 5 * time.Second}, - logger: slog.Default(), - } - - maxCalls := 5 - req := triggerScheduleRequest{ - Model: gatewayModelConfig{ - ModelID: "gpt-4", - ClientType: "openai", - APIKey: "sk-test", - BaseURL: "https://api.openai.com", - }, - ActiveContextTime: 1440, - Channels: []string{}, - Messages: []ModelMessage{}, - Skills: []string{}, - Identity: gatewayIdentity{ - BotID: "bot-123", - ContainerID: "mcp-bot-123", - ChannelIdentityID: "owner-user-1", - DisplayName: "Scheduler", - }, - Attachments: []any{}, - Schedule: gatewaySchedule{ - ID: "sched-1", - Name: "daily report", - Description: "generate daily report", - Pattern: "0 9 * * *", - MaxCalls: &maxCalls, - Command: "generate the daily report", - }, - } - - resp, err := resolver.postTriggerSchedule(context.Background(), req, "Bearer test-token") - if err != nil { - t.Fatalf("postTriggerSchedule returned error: %v", err) - } - - if capturedPath != "/chat/trigger-schedule" { - t.Errorf("expected path /chat/trigger-schedule, got %s", capturedPath) - } - if capturedAuth != "Bearer test-token" { - t.Errorf("expected Authorization header 'Bearer test-token', got %s", capturedAuth) - } - if len(resp.Messages) != 1 { - t.Errorf("expected 1 message, got %d", len(resp.Messages)) - } - - var body map[string]any - if err := json.Unmarshal(capturedBody, &body); err != nil { - t.Fatalf("failed to parse captured body: %v", err) - } - schedule, ok := body["schedule"].(map[string]any) - if !ok { - t.Fatal("expected 'schedule' field in request body") - } - if schedule["id"] != "sched-1" { - t.Errorf("expected schedule.id=sched-1, got %v", schedule["id"]) - } - if schedule["command"] != "generate the daily report" { - t.Errorf("expected schedule.command, got %v", schedule["command"]) - } - if _, hasQuery := body["query"]; hasQuery { - t.Error("trigger-schedule request should not contain 'query' field") - } -} - -func TestPostTriggerSchedule_NoAuth(t *testing.T) { - var capturedAuth string - - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - capturedAuth = r.Header.Get("Authorization") - resp := gatewayResponse{Messages: []ModelMessage{}} - json.NewEncoder(w).Encode(resp) - })) - defer srv.Close() - - resolver := &Resolver{ - gatewayBaseURL: srv.URL, - httpClient: &http.Client{Timeout: 5 * time.Second}, - logger: slog.Default(), - } - - req := triggerScheduleRequest{ - Channels: []string{}, - Messages: []ModelMessage{}, - Skills: []string{}, - Attachments: []any{}, - Schedule: gatewaySchedule{ID: "s1", Command: "test"}, - } - - _, err := resolver.postTriggerSchedule(context.Background(), req, "") - if err != nil { - t.Fatalf("postTriggerSchedule returned error: %v", err) - } - if capturedAuth != "" { - t.Errorf("expected no Authorization header, got %s", capturedAuth) - } -} - -func TestPostTriggerSchedule_GatewayError(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusInternalServerError) - w.Write([]byte("internal error")) - })) - defer srv.Close() - - resolver := &Resolver{ - gatewayBaseURL: srv.URL, - httpClient: &http.Client{Timeout: 5 * time.Second}, - logger: slog.Default(), - } - - req := triggerScheduleRequest{ - Channels: []string{}, - Messages: []ModelMessage{}, - Skills: []string{}, - Attachments: []any{}, - Schedule: gatewaySchedule{ID: "s1", Command: "test"}, - } - - _, err := resolver.postTriggerSchedule(context.Background(), req, "Bearer tok") - if err == nil { - t.Fatal("expected error for 500 response") - } -} diff --git a/internal/chat/schedule_gateway.go b/internal/chat/schedule_gateway.go deleted file mode 100644 index 8e543f78..00000000 --- a/internal/chat/schedule_gateway.go +++ /dev/null @@ -1,26 +0,0 @@ -package conversation - -import ( - "context" - "fmt" - - "github.com/memohai/memoh/internal/schedule" -) - -// ScheduleGateway adapts schedule trigger calls to the chat Resolver. -type ScheduleGateway struct { - resolver *Resolver -} - -// NewScheduleGateway creates a ScheduleGateway backed by the given Resolver. -func NewScheduleGateway(resolver *Resolver) *ScheduleGateway { - return &ScheduleGateway{resolver: resolver} -} - -// TriggerSchedule delegates a schedule trigger to the chat Resolver. -func (g *ScheduleGateway) TriggerSchedule(ctx context.Context, botID string, payload schedule.TriggerPayload, token string) error { - if g == nil || g.resolver == nil { - return fmt.Errorf("chat resolver not configured") - } - return g.resolver.TriggerSchedule(ctx, botID, payload, token) -} diff --git a/internal/chat/service.go b/internal/chat/service.go deleted file mode 100644 index 1ce4a6e5..00000000 --- a/internal/chat/service.go +++ /dev/null @@ -1,1000 +0,0 @@ -package conversation - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "log/slog" - "strings" - "time" - - "github.com/jackc/pgx/v5" - "github.com/jackc/pgx/v5/pgtype" - - "github.com/memohai/memoh/internal/db" - "github.com/memohai/memoh/internal/db/sqlc" -) - -var ( - ErrChatNotFound = errors.New("chat not found") - ErrNotParticipant = errors.New("not a participant") - ErrPermissionDenied = errors.New("permission denied") -) - -// Service manages chat lifecycle, participants, settings, and routes. -type Service struct { - queries *sqlc.Queries - logger *slog.Logger -} - -// NewService creates a chat service. -func NewService(log *slog.Logger, queries *sqlc.Queries) *Service { - if log == nil { - log = slog.Default() - } - return &Service{ - queries: queries, - logger: log.With(slog.String("service", "chat")), - } -} - -// --- Chat CRUD --- - -// Create creates a new chat and adds the creator as owner. -func (s *Service) Create(ctx context.Context, botID, channelIdentityID string, req CreateRequest) (Chat, error) { - kind := strings.TrimSpace(req.Kind) - if kind == "" { - kind = KindDirect - } - if kind != KindDirect && kind != KindGroup && kind != KindThread { - return Chat{}, fmt.Errorf("invalid chat kind: %s", kind) - } - - pgBotID, err := parseUUID(botID) - if err != nil { - return Chat{}, fmt.Errorf("invalid bot id: %w", err) - } - pgChannelIdentityID := pgtype.UUID{} - if strings.TrimSpace(channelIdentityID) != "" { - pgChannelIdentityID, err = parseUUID(channelIdentityID) - if err != nil { - return Chat{}, fmt.Errorf("invalid user id: %w", err) - } - } - - var pgParent pgtype.UUID - if kind == KindThread && strings.TrimSpace(req.ParentChatID) != "" { - pgParent, err = parseUUID(req.ParentChatID) - if err != nil { - return Chat{}, fmt.Errorf("invalid parent chat id: %w", err) - } - } - - metadata, err := json.Marshal(nonNilMap(req.Metadata)) - if err != nil { - return Chat{}, fmt.Errorf("marshal chat metadata: %w", err) - } - - row, err := s.queries.CreateChat(ctx, sqlc.CreateChatParams{ - BotID: pgBotID, - Kind: kind, - ParentChatID: pgParent, - Title: strings.TrimSpace(req.Title), - CreatedByUserID: pgChannelIdentityID, - Metadata: metadata, - }) - if err != nil { - return Chat{}, fmt.Errorf("create chat: %w", err) - } - - // Add creator as owner when user identity is available. - if pgChannelIdentityID.Valid { - if _, err := s.queries.AddChatParticipant(ctx, sqlc.AddChatParticipantParams{ - ChatID: row.ID, - UserID: pgChannelIdentityID, - Role: RoleOwner, - }); err != nil { - return Chat{}, fmt.Errorf("add owner participant: %w", err) - } - } - - // For threads, copy participants from parent. - if kind == KindThread && pgParent.Valid { - if err := s.queries.CopyParticipantsToChat(ctx, sqlc.CopyParticipantsToChatParams{ - ChatID: pgParent, - ChatID2: row.ID, - }); err != nil { - s.logger.Warn("copy parent participants failed", slog.Any("error", err)) - } - } - - return toChatFromCreate(row), nil -} - -// Get returns a chat by ID. -func (s *Service) Get(ctx context.Context, chatID string) (Chat, error) { - pgID, err := parseUUID(chatID) - if err != nil { - return Chat{}, ErrChatNotFound - } - row, err := s.queries.GetChatByID(ctx, pgID) - if err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return Chat{}, ErrChatNotFound - } - return Chat{}, err - } - return toChatFromGet(row), nil -} - -// GetReadAccess resolves whether a user can read a chat. -func (s *Service) GetReadAccess(ctx context.Context, chatID, channelIdentityID string) (ChatReadAccess, error) { - pgChatID, err := parseUUID(chatID) - if err != nil { - return ChatReadAccess{}, ErrPermissionDenied - } - pgChannelIdentityID, err := parseUUID(channelIdentityID) - if err != nil { - return ChatReadAccess{}, ErrPermissionDenied - } - row, err := s.queries.GetChatReadAccessByUser(ctx, sqlc.GetChatReadAccessByUserParams{ - ChatID: pgChatID, - UserID: pgChannelIdentityID, - }) - if err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return ChatReadAccess{}, ErrPermissionDenied - } - return ChatReadAccess{}, err - } - return ChatReadAccess{ - AccessMode: row.AccessMode, - ParticipantRole: strings.TrimSpace(row.ParticipantRole), - LastObservedAt: pgTimePtr(row.LastObservedAt), - }, nil -} - -// ListByBotAndChannelIdentity returns all chats visible to the user for a bot. -func (s *Service) ListByBotAndChannelIdentity(ctx context.Context, botID, channelIdentityID string) ([]ChatListItem, error) { - pgBotID, err := parseUUID(botID) - if err != nil { - return nil, err - } - pgChannelIdentityID, err := parseUUID(channelIdentityID) - if err != nil { - return nil, err - } - rows, err := s.queries.ListVisibleChatsByBotAndUser(ctx, sqlc.ListVisibleChatsByBotAndUserParams{ - BotID: pgBotID, - UserID: pgChannelIdentityID, - }) - if err != nil { - return nil, err - } - chats := make([]ChatListItem, 0, len(rows)) - for _, row := range rows { - chats = append(chats, toChatListItem(row)) - } - return chats, nil -} - -// ListThreads returns threads for a parent chat. -func (s *Service) ListThreads(ctx context.Context, parentChatID string) ([]Chat, error) { - pgID, err := parseUUID(parentChatID) - if err != nil { - return nil, err - } - rows, err := s.queries.ListThreadsByParent(ctx, pgID) - if err != nil { - return nil, err - } - chats := make([]Chat, 0, len(rows)) - for _, row := range rows { - chats = append(chats, toChatFromThread(row)) - } - return chats, nil -} - -// Delete deletes a chat (cascade deletes messages, routes, participants, settings). -func (s *Service) Delete(ctx context.Context, chatID string) error { - pgID, err := parseUUID(chatID) - if err != nil { - return ErrChatNotFound - } - return s.queries.DeleteChat(ctx, pgID) -} - -// --- Participants --- - -// AddParticipant adds a user identity to a chat. -func (s *Service) AddParticipant(ctx context.Context, chatID, channelIdentityID, role string) (Participant, error) { - pgChatID, err := parseUUID(chatID) - if err != nil { - return Participant{}, err - } - pgChannelIdentityID, err := parseUUID(channelIdentityID) - if err != nil { - return Participant{}, err - } - if role == "" { - role = RoleMember - } - row, err := s.queries.AddChatParticipant(ctx, sqlc.AddChatParticipantParams{ - ChatID: pgChatID, - UserID: pgChannelIdentityID, - Role: role, - }) - if err != nil { - return Participant{}, err - } - return toParticipantFromAdd(row), nil -} - -// GetParticipant returns a participant record. -func (s *Service) GetParticipant(ctx context.Context, chatID, channelIdentityID string) (Participant, error) { - pgChatID, err := parseUUID(chatID) - if err != nil { - return Participant{}, ErrNotParticipant - } - pgChannelIdentityID, err := parseUUID(channelIdentityID) - if err != nil { - return Participant{}, ErrNotParticipant - } - row, err := s.queries.GetChatParticipant(ctx, sqlc.GetChatParticipantParams{ - ChatID: pgChatID, - UserID: pgChannelIdentityID, - }) - if err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return Participant{}, ErrNotParticipant - } - return Participant{}, err - } - return toParticipantFromGet(row), nil -} - -// IsParticipant checks whether a user identity is a participant in a chat. -func (s *Service) IsParticipant(ctx context.Context, chatID, channelIdentityID string) (bool, error) { - _, err := s.GetParticipant(ctx, chatID, channelIdentityID) - if errors.Is(err, ErrNotParticipant) { - return false, nil - } - return err == nil, err -} - -// ListParticipants returns all participants for a chat. -func (s *Service) ListParticipants(ctx context.Context, chatID string) ([]Participant, error) { - pgID, err := parseUUID(chatID) - if err != nil { - return nil, err - } - rows, err := s.queries.ListChatParticipants(ctx, pgID) - if err != nil { - return nil, err - } - participants := make([]Participant, 0, len(rows)) - for _, row := range rows { - participants = append(participants, toParticipantFromList(row)) - } - return participants, nil -} - -// RemoveParticipant removes a user identity from a chat. -func (s *Service) RemoveParticipant(ctx context.Context, chatID, channelIdentityID string) error { - pgChatID, err := parseUUID(chatID) - if err != nil { - return err - } - pgChannelIdentityID, err := parseUUID(channelIdentityID) - if err != nil { - return err - } - return s.queries.RemoveChatParticipant(ctx, sqlc.RemoveChatParticipantParams{ - ChatID: pgChatID, - UserID: pgChannelIdentityID, - }) -} - -// --- Settings --- - -// GetSettings returns settings for a chat. Returns defaults if not found. -func (s *Service) GetSettings(ctx context.Context, chatID string) (Settings, error) { - pgID, err := parseUUID(chatID) - if err != nil { - return defaultSettings(chatID), nil - } - row, err := s.queries.GetChatSettings(ctx, pgID) - if err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return defaultSettings(chatID), nil - } - return Settings{}, err - } - return toSettingsFromRead(row), nil -} - -// UpdateSettings updates chat settings. -func (s *Service) UpdateSettings(ctx context.Context, chatID string, req UpdateSettingsRequest) (Settings, error) { - current, err := s.GetSettings(ctx, chatID) - if err != nil { - return Settings{}, err - } - if req.ModelID != nil { - current.ModelID = *req.ModelID - } - - pgID, err := parseUUID(chatID) - if err != nil { - return Settings{}, err - } - row, err := s.queries.UpsertChatSettings(ctx, sqlc.UpsertChatSettingsParams{ - ID: pgID, - ModelID: toPgText(current.ModelID), - }) - if err != nil { - return Settings{}, err - } - return toSettingsFromUpsert(row), nil -} - -// --- Routes --- - -// CreateRoute creates a new chat route. -func (s *Service) CreateRoute(ctx context.Context, chatID string, r Route) (Route, error) { - pgChatID, err := parseUUID(chatID) - if err != nil { - return Route{}, err - } - pgBotID, err := parseUUID(r.BotID) - if err != nil { - return Route{}, err - } - var pgConfigID pgtype.UUID - if strings.TrimSpace(r.ChannelConfigID) != "" { - pgConfigID, err = parseUUID(r.ChannelConfigID) - if err != nil { - return Route{}, err - } - } - metadata, err := json.Marshal(nonNilMap(r.Metadata)) - if err != nil { - return Route{}, fmt.Errorf("marshal route metadata: %w", err) - } - row, err := s.queries.CreateChatRoute(ctx, sqlc.CreateChatRouteParams{ - ChatID: pgChatID, - BotID: pgBotID, - Platform: r.Platform, - ChannelConfigID: pgConfigID, - ConversationID: r.ConversationID, - ThreadID: toPgText(r.ThreadID), - ReplyTarget: toPgText(r.ReplyTarget), - Metadata: metadata, - }) - if err != nil { - return Route{}, fmt.Errorf("create route: %w", err) - } - return toRouteFromCreate(row), nil -} - -// FindRoute looks up a route by (bot_id, platform, conversation_id, thread_id). -func (s *Service) FindRoute(ctx context.Context, botID, platform, conversationID, threadID string) (Route, error) { - pgBotID, err := parseUUID(botID) - if err != nil { - return Route{}, err - } - row, err := s.queries.FindChatRoute(ctx, sqlc.FindChatRouteParams{ - BotID: pgBotID, - Platform: platform, - ConversationID: conversationID, - ThreadID: toPgText(threadID), - }) - if err != nil { - return Route{}, err - } - return toRouteFromFind(row), nil -} - -// GetRouteByID returns a single route by its ID. -func (s *Service) GetRouteByID(ctx context.Context, routeID string) (Route, error) { - pgID, err := parseUUID(routeID) - if err != nil { - return Route{}, err - } - row, err := s.queries.GetChatRouteByID(ctx, pgID) - if err != nil { - return Route{}, err - } - return toRouteFromGet(row), nil -} - -// ListRoutes lists all routes for a chat. -func (s *Service) ListRoutes(ctx context.Context, chatID string) ([]Route, error) { - pgID, err := parseUUID(chatID) - if err != nil { - return nil, err - } - rows, err := s.queries.ListChatRoutes(ctx, pgID) - if err != nil { - return nil, err - } - routes := make([]Route, 0, len(rows)) - for _, row := range rows { - routes = append(routes, toRouteFromList(row)) - } - return routes, nil -} - -// DeleteRoute deletes a route. -func (s *Service) DeleteRoute(ctx context.Context, routeID string) error { - pgID, err := parseUUID(routeID) - if err != nil { - return err - } - return s.queries.DeleteChatRoute(ctx, pgID) -} - -// UpdateRouteReplyTarget updates the reply target for a route. -func (s *Service) UpdateRouteReplyTarget(ctx context.Context, routeID, replyTarget string) error { - pgID, err := parseUUID(routeID) - if err != nil { - return err - } - return s.queries.UpdateChatRouteReplyTarget(ctx, sqlc.UpdateChatRouteReplyTargetParams{ - ID: pgID, - ReplyTarget: toPgText(replyTarget), - }) -} - -// --- ResolveChat --- - -// ResolveChat finds or creates a chat for a channel inbound message. -func (s *Service) ResolveChat(ctx context.Context, botID, platform, conversationID, threadID, conversationType, channelIdentityID, channelConfigID, replyTarget string) (ResolveChatResult, error) { - // Look up existing route. - route, err := s.FindRoute(ctx, botID, platform, conversationID, threadID) - if err == nil { - // Route found, ensure the sender identity is a participant. - if strings.TrimSpace(channelIdentityID) != "" { - ok, checkErr := s.IsParticipant(ctx, route.ChatID, channelIdentityID) - if checkErr != nil { - return ResolveChatResult{}, fmt.Errorf("check chat participant: %w", checkErr) - } - if !ok { - if _, err := s.AddParticipant(ctx, route.ChatID, channelIdentityID, RoleMember); err != nil { - s.logger.Warn("auto-add participant failed", slog.Any("error", err)) - } - } - } - // Update reply target if changed. - if strings.TrimSpace(replyTarget) != "" && replyTarget != route.ReplyTarget { - if err := s.UpdateRouteReplyTarget(ctx, route.ID, replyTarget); err != nil && s.logger != nil { - s.logger.Warn("update route reply target failed", slog.Any("error", err)) - } - } - pgRouteChatID, parseErr := parseUUID(route.ChatID) - if parseErr != nil { - return ResolveChatResult{}, fmt.Errorf("parse route chat id: %w", parseErr) - } - if err := s.queries.TouchChat(ctx, pgRouteChatID); err != nil && s.logger != nil { - s.logger.Warn("touch chat failed", slog.Any("error", err)) - } - return ResolveChatResult{ChatID: route.ChatID, RouteID: route.ID, Created: false}, nil - } - - // Route not found, create chat + route + participant. - kind := determineChatKind(threadID, conversationType) - creatorChannelIdentityID := s.resolveChatCreatorChannelIdentityID(ctx, botID, channelIdentityID, kind) - - var parentChatID string - if kind == KindThread { - parentRoute, parentErr := s.FindRoute(ctx, botID, platform, conversationID, "") - if parentErr == nil { - parentChatID = parentRoute.ChatID - } - } - - c, err := s.Create(ctx, botID, creatorChannelIdentityID, CreateRequest{ - Kind: kind, - ParentChatID: parentChatID, - }) - if err != nil { - return ResolveChatResult{}, fmt.Errorf("create chat: %w", err) - } - if strings.TrimSpace(channelIdentityID) != "" && strings.TrimSpace(channelIdentityID) != strings.TrimSpace(creatorChannelIdentityID) { - if _, err := s.AddParticipant(ctx, c.ID, channelIdentityID, RoleMember); err != nil { - s.logger.Warn("auto-add creator participant failed", slog.Any("error", err)) - } - } - - newRoute, err := s.CreateRoute(ctx, c.ID, Route{ - BotID: botID, - Platform: platform, - ChannelConfigID: channelConfigID, - ConversationID: conversationID, - ThreadID: threadID, - ReplyTarget: replyTarget, - }) - if err != nil { - return ResolveChatResult{}, fmt.Errorf("create route: %w", err) - } - - return ResolveChatResult{ChatID: c.ID, RouteID: newRoute.ID, Created: true}, nil -} - -// --- Messages --- - -// PersistMessage writes a single message to bot_history_messages. -func (s *Service) PersistMessage(ctx context.Context, botID, routeID, senderChannelIdentityID, senderUserID, platform, externalMessageID, sourceReplyToMessageID, role string, content json.RawMessage, metadata map[string]any) (Message, error) { - pgBotID, err := parseUUID(botID) - if err != nil { - return Message{}, err - } - var pgRouteID pgtype.UUID - if strings.TrimSpace(routeID) != "" { - pgRouteID, err = parseUUID(routeID) - if err != nil { - return Message{}, err - } - } - var pgSender pgtype.UUID - if strings.TrimSpace(senderChannelIdentityID) != "" { - pgSender, err = parseUUID(senderChannelIdentityID) - if err != nil { - return Message{}, fmt.Errorf("invalid sender channel identity id: %w", err) - } - } - var pgSenderUser pgtype.UUID - if strings.TrimSpace(senderUserID) != "" { - pgSenderUser, err = parseUUID(senderUserID) - if err != nil { - return Message{}, fmt.Errorf("invalid sender user id: %w", err) - } - } - metaBytes, err := json.Marshal(nonNilMap(metadata)) - if err != nil { - return Message{}, fmt.Errorf("marshal message metadata: %w", err) - } - if len(content) == 0 { - content = []byte("{}") - } - - row, err := s.queries.CreateMessage(ctx, sqlc.CreateMessageParams{ - BotID: pgBotID, - RouteID: pgRouteID, - SenderChannelIdentityID: pgSender, - SenderUserID: pgSenderUser, - Platform: toPgText(platform), - ExternalMessageID: toPgText(externalMessageID), - SourceReplyToMessageID: toPgText(sourceReplyToMessageID), - Role: role, - Content: content, - Metadata: metaBytes, - }) - if err != nil { - return Message{}, err - } - return toMessageFromCreate(row), nil -} - -// ListMessages returns all messages for a bot. -func (s *Service) ListMessages(ctx context.Context, botID string) ([]Message, error) { - pgID, err := parseUUID(botID) - if err != nil { - return nil, err - } - rows, err := s.queries.ListMessages(ctx, pgID) - if err != nil { - return nil, err - } - return toMessagesFromList(rows), nil -} - -// ListMessagesSince returns bot messages since a given time. -func (s *Service) ListMessagesSince(ctx context.Context, botID string, since time.Time) ([]Message, error) { - pgID, err := parseUUID(botID) - if err != nil { - return nil, err - } - rows, err := s.queries.ListMessagesSince(ctx, sqlc.ListMessagesSinceParams{ - BotID: pgID, - CreatedAt: pgtype.Timestamptz{Time: since, Valid: true}, - }) - if err != nil { - return nil, err - } - return toMessagesFromSince(rows), nil -} - -// ListMessagesLatest returns the latest N bot messages (most recent first). -func (s *Service) ListMessagesLatest(ctx context.Context, botID string, limit int32) ([]Message, error) { - pgID, err := parseUUID(botID) - if err != nil { - return nil, err - } - rows, err := s.queries.ListMessagesLatest(ctx, sqlc.ListMessagesLatestParams{ - BotID: pgID, - MaxCount: limit, - }) - if err != nil { - return nil, err - } - return toMessagesFromLatest(rows), nil -} - -// DeleteMessages deletes all messages for a bot. -func (s *Service) DeleteMessages(ctx context.Context, botID string) error { - pgID, err := parseUUID(botID) - if err != nil { - return err - } - return s.queries.DeleteMessagesByBot(ctx, pgID) -} - -// --- conversion helpers --- - -func toChatFromCreate(row sqlc.CreateChatRow) Chat { - return toChatFields( - row.ID, - row.BotID, - row.Kind, - row.ParentChatID, - row.Title, - row.CreatedByUserID, - row.Metadata, - row.CreatedAt, - row.UpdatedAt, - ) -} - -func toChatFromGet(row sqlc.GetChatByIDRow) Chat { - return toChatFields( - row.ID, - row.BotID, - row.Kind, - row.ParentChatID, - row.Title, - row.CreatedByUserID, - row.Metadata, - row.CreatedAt, - row.UpdatedAt, - ) -} - -func toChatFromThread(row sqlc.ListThreadsByParentRow) Chat { - return toChatFields( - row.ID, - row.BotID, - row.Kind, - row.ParentChatID, - row.Title, - row.CreatedByUserID, - row.Metadata, - row.CreatedAt, - row.UpdatedAt, - ) -} - -func toChatFields(id, botID pgtype.UUID, kind string, parentChatID pgtype.UUID, title pgtype.Text, createdBy pgtype.UUID, metadata []byte, createdAt, updatedAt pgtype.Timestamptz) Chat { - return Chat{ - ID: id.String(), - BotID: botID.String(), - Kind: kind, - ParentChatID: parentChatID.String(), - Title: db.TextToString(title), - CreatedBy: createdBy.String(), - Metadata: parseJSONMap(metadata), - CreatedAt: createdAt.Time, - UpdatedAt: updatedAt.Time, - } -} - -func toChatListItem(row sqlc.ListVisibleChatsByBotAndUserRow) ChatListItem { - return ChatListItem{ - ID: row.ID.String(), - BotID: row.BotID.String(), - Kind: row.Kind, - ParentChatID: row.ParentChatID.String(), - Title: db.TextToString(row.Title), - CreatedBy: row.CreatedByUserID.String(), - Metadata: parseJSONMap(row.Metadata), - CreatedAt: row.CreatedAt.Time, - UpdatedAt: row.UpdatedAt.Time, - AccessMode: row.AccessMode, - ParticipantRole: strings.TrimSpace(row.ParticipantRole), - LastObservedAt: pgTimePtr(row.LastObservedAt), - } -} - -func toParticipantFromAdd(row sqlc.AddChatParticipantRow) Participant { - return toParticipantFields(row.ChatID, row.UserID, row.Role, row.JoinedAt) -} - -func toParticipantFromGet(row sqlc.GetChatParticipantRow) Participant { - return toParticipantFields(row.ChatID, row.UserID, row.Role, row.JoinedAt) -} - -func toParticipantFromList(row sqlc.ListChatParticipantsRow) Participant { - return toParticipantFields(row.ChatID, row.UserID, row.Role, row.JoinedAt) -} - -func toParticipantFields(chatID, userID pgtype.UUID, role string, joinedAt pgtype.Timestamptz) Participant { - return Participant{ - ChatID: chatID.String(), - UserID: userID.String(), - Role: role, - JoinedAt: joinedAt.Time, - } -} - -func toSettingsFromRead(row sqlc.GetChatSettingsRow) Settings { - return Settings{ - ChatID: row.ChatID.String(), - ModelID: db.TextToString(row.ModelID), - } -} - -func toSettingsFromUpsert(row sqlc.UpsertChatSettingsRow) Settings { - return Settings{ - ChatID: row.ChatID.String(), - ModelID: db.TextToString(row.ModelID), - } -} - -func toRouteFromCreate(row sqlc.CreateChatRouteRow) Route { - return toRouteFields( - row.ID, - row.ChatID, - row.BotID, - row.Platform, - row.ChannelConfigID, - row.ConversationID, - row.ThreadID, - row.ReplyTarget, - row.Metadata, - row.CreatedAt, - row.UpdatedAt, - ) -} - -func toRouteFromFind(row sqlc.FindChatRouteRow) Route { - return toRouteFields( - row.ID, - row.ChatID, - row.BotID, - row.Platform, - row.ChannelConfigID, - row.ConversationID, - row.ThreadID, - row.ReplyTarget, - row.Metadata, - row.CreatedAt, - row.UpdatedAt, - ) -} - -func toRouteFromGet(row sqlc.GetChatRouteByIDRow) Route { - return toRouteFields( - row.ID, - row.ChatID, - row.BotID, - row.Platform, - row.ChannelConfigID, - row.ConversationID, - row.ThreadID, - row.ReplyTarget, - row.Metadata, - row.CreatedAt, - row.UpdatedAt, - ) -} - -func toRouteFromList(row sqlc.ListChatRoutesRow) Route { - return toRouteFields( - row.ID, - row.ChatID, - row.BotID, - row.Platform, - row.ChannelConfigID, - row.ConversationID, - row.ThreadID, - row.ReplyTarget, - row.Metadata, - row.CreatedAt, - row.UpdatedAt, - ) -} - -func toRouteFields(id, chatID, botID pgtype.UUID, platform string, channelConfigID pgtype.UUID, conversationID string, threadID, replyTarget pgtype.Text, metadata []byte, createdAt, updatedAt pgtype.Timestamptz) Route { - return Route{ - ID: id.String(), - ChatID: chatID.String(), - BotID: botID.String(), - Platform: platform, - ChannelConfigID: channelConfigID.String(), - ConversationID: conversationID, - ThreadID: db.TextToString(threadID), - ReplyTarget: db.TextToString(replyTarget), - Metadata: parseJSONMap(metadata), - CreatedAt: createdAt.Time, - UpdatedAt: updatedAt.Time, - } -} - -func toMessageFromCreate(row sqlc.CreateMessageRow) Message { - return toMessageFields( - row.ID, - row.BotID, - row.RouteID, - row.SenderChannelIdentityID, - row.SenderUserID, - row.Platform, - row.ExternalMessageID, - row.SourceReplyToMessageID, - row.Role, - row.Content, - row.Metadata, - row.CreatedAt, - ) -} - -func toMessageFromListRow(row sqlc.ListMessagesRow) Message { - return toMessageFields( - row.ID, - row.BotID, - row.RouteID, - row.SenderChannelIdentityID, - row.SenderUserID, - row.Platform, - row.ExternalMessageID, - row.SourceReplyToMessageID, - row.Role, - row.Content, - row.Metadata, - row.CreatedAt, - ) -} - -func toMessageFromSinceRow(row sqlc.ListMessagesSinceRow) Message { - return toMessageFields( - row.ID, - row.BotID, - row.RouteID, - row.SenderChannelIdentityID, - row.SenderUserID, - row.Platform, - row.ExternalMessageID, - row.SourceReplyToMessageID, - row.Role, - row.Content, - row.Metadata, - row.CreatedAt, - ) -} - -func toMessageFromLatestRow(row sqlc.ListMessagesLatestRow) Message { - return toMessageFields( - row.ID, - row.BotID, - row.RouteID, - row.SenderChannelIdentityID, - row.SenderUserID, - row.Platform, - row.ExternalMessageID, - row.SourceReplyToMessageID, - row.Role, - row.Content, - row.Metadata, - row.CreatedAt, - ) -} - -func toMessageFields(id, botID, routeID, senderChannelIdentityID, senderUserID pgtype.UUID, platform, externalMessageID, sourceReplyToMessageID pgtype.Text, role string, content, metadata []byte, createdAt pgtype.Timestamptz) Message { - return Message{ - ID: id.String(), - BotID: botID.String(), - RouteID: routeID.String(), - SenderChannelIdentityID: senderChannelIdentityID.String(), - SenderUserID: senderUserID.String(), - Platform: db.TextToString(platform), - ExternalMessageID: db.TextToString(externalMessageID), - SourceReplyToMessageID: db.TextToString(sourceReplyToMessageID), - Role: role, - Content: json.RawMessage(content), - Metadata: parseJSONMap(metadata), - CreatedAt: createdAt.Time, - } -} - -func toMessagesFromList(rows []sqlc.ListMessagesRow) []Message { - msgs := make([]Message, 0, len(rows)) - for _, row := range rows { - msgs = append(msgs, toMessageFromListRow(row)) - } - return msgs -} - -func toMessagesFromSince(rows []sqlc.ListMessagesSinceRow) []Message { - msgs := make([]Message, 0, len(rows)) - for _, row := range rows { - msgs = append(msgs, toMessageFromSinceRow(row)) - } - return msgs -} - -func toMessagesFromLatest(rows []sqlc.ListMessagesLatestRow) []Message { - msgs := make([]Message, 0, len(rows)) - for _, row := range rows { - msgs = append(msgs, toMessageFromLatestRow(row)) - } - return msgs -} - -func defaultSettings(chatID string) Settings { - return Settings{ - ChatID: chatID, - } -} - -func determineChatKind(threadID, conversationType string) string { - if strings.TrimSpace(threadID) != "" { - return KindThread - } - ct := strings.ToLower(strings.TrimSpace(conversationType)) - if ct == "p2p" || ct == "private" || ct == "" { - return KindDirect - } - return KindGroup -} - -func toPgText(s string) pgtype.Text { - s = strings.TrimSpace(s) - if s == "" { - return pgtype.Text{} - } - return pgtype.Text{String: s, Valid: true} -} - -func pgTimePtr(ts pgtype.Timestamptz) *time.Time { - if !ts.Valid { - return nil - } - value := ts.Time - return &value -} - -func nonNilMap(m map[string]any) map[string]any { - if m == nil { - return map[string]any{} - } - return m -} - -func parseJSONMap(data []byte) map[string]any { - if len(data) == 0 { - return nil - } - var m map[string]any - _ = json.Unmarshal(data, &m) - return m -} - -func (s *Service) resolveChatCreatorChannelIdentityID(ctx context.Context, botID, fallbackChannelIdentityID, kind string) string { - fallback := strings.TrimSpace(fallbackChannelIdentityID) - if kind != KindGroup || s.queries == nil { - return fallback - } - pgBotID, err := parseUUID(botID) - if err != nil { - return fallback - } - row, err := s.queries.GetBotByID(ctx, pgBotID) - if err != nil { - s.logger.Warn("resolve bot owner for group chat failed", slog.Any("error", err)) - return fallback - } - ownerChannelIdentityID := row.OwnerUserID.String() - if strings.TrimSpace(ownerChannelIdentityID) == "" { - return fallback - } - return ownerChannelIdentityID -} diff --git a/internal/chat/service_presence_integration_test.go b/internal/chat/service_presence_integration_test.go deleted file mode 100644 index 9b07851b..00000000 --- a/internal/chat/service_presence_integration_test.go +++ /dev/null @@ -1,244 +0,0 @@ -package conversation_test - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "log/slog" - "os" - "testing" - "time" - - "github.com/jackc/pgx/v5/pgtype" - "github.com/jackc/pgx/v5/pgxpool" - - "github.com/memohai/memoh/internal/channel/identities" - conversation "github.com/memohai/memoh/internal/chat" - "github.com/memohai/memoh/internal/db" - "github.com/memohai/memoh/internal/db/sqlc" -) - -type chatPresenceFixture struct { - chatSvc *conversation.Service - channelIdentitySvc *identities.Service - queries *sqlc.Queries - cleanup func() -} - -func setupChatPresenceIntegrationTest(t *testing.T) chatPresenceFixture { - t.Helper() - - dsn := os.Getenv("TEST_POSTGRES_DSN") - if dsn == "" { - t.Skip("skip integration test: TEST_POSTGRES_DSN is not set") - } - - ctx := context.Background() - pool, err := pgxpool.New(ctx, dsn) - if err != nil { - t.Skipf("skip integration test: cannot connect to database: %v", err) - } - if err := pool.Ping(ctx); err != nil { - pool.Close() - t.Skipf("skip integration test: database ping failed: %v", err) - } - - logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug})) - queries := sqlc.New(pool) - - return chatPresenceFixture{ - chatSvc: conversation.NewService(logger, queries), - channelIdentitySvc: identities.NewService(logger, queries), - queries: queries, - cleanup: func() { pool.Close() }, - } -} - -func createUserForChatPresence(ctx context.Context, queries *sqlc.Queries) (string, error) { - row, err := queries.CreateUser(ctx, sqlc.CreateUserParams{ - IsActive: true, - Metadata: []byte("{}"), - }) - if err != nil { - return "", err - } - return row.ID.String(), nil -} - -func createBotForChatPresence(ctx context.Context, queries *sqlc.Queries, ownerUserID string) (string, error) { - pgOwnerID, err := db.ParseUUID(ownerUserID) - if err != nil { - return "", err - } - meta, err := json.Marshal(map[string]any{"source": "chat-presence-integration-test"}) - if err != nil { - return "", err - } - row, err := queries.CreateBot(ctx, sqlc.CreateBotParams{ - OwnerUserID: pgOwnerID, - Type: "personal", - DisplayName: pgtype.Text{String: "presence-test-bot", Valid: true}, - IsActive: true, - Metadata: meta, - }) - if err != nil { - return "", err - } - return row.ID.String(), nil -} - -func setupObservedChatScenario(t *testing.T) (chatPresenceFixture, string, string, string, string) { - t.Helper() - - fixture := setupChatPresenceIntegrationTest(t) - ctx := context.Background() - - ownerUserID, err := createUserForChatPresence(ctx, fixture.queries) - if err != nil { - fixture.cleanup() - t.Fatalf("create owner user failed: %v", err) - } - observerUserID, err := createUserForChatPresence(ctx, fixture.queries) - if err != nil { - fixture.cleanup() - t.Fatalf("create observer user failed: %v", err) - } - botID, err := createBotForChatPresence(ctx, fixture.queries, ownerUserID) - if err != nil { - fixture.cleanup() - t.Fatalf("create bot failed: %v", err) - } - - createdChat, err := fixture.chatSvc.Create(ctx, botID, ownerUserID, conversation.CreateRequest{ - Kind: conversation.KindGroup, - Title: "presence-observed", - }) - if err != nil { - fixture.cleanup() - t.Fatalf("create chat failed: %v", err) - } - - observedChannelIdentity, err := fixture.channelIdentitySvc.ResolveByChannelIdentity( - ctx, - "feishu", - fmt.Sprintf("presence-channelIdentity-%d", time.Now().UnixNano()), - "presence-observer", - ) - if err != nil { - fixture.cleanup() - t.Fatalf("resolve channelIdentity failed: %v", err) - } - - _, err = fixture.chatSvc.PersistMessage( - ctx, - botID, - "", - observedChannelIdentity.ID, - "", - "feishu", - fmt.Sprintf("ext-msg-%d", time.Now().UnixNano()), - "", - "user", - []byte(`{"content":"hello from observed channelIdentity"}`), - nil, - ) - if err != nil { - fixture.cleanup() - t.Fatalf("persist message failed: %v", err) - } - - return fixture, botID, createdChat.ID, observerUserID, observedChannelIdentity.ID -} - -func TestObservedChatVisibleAfterBindWithoutBackfill(t *testing.T) { - fixture, botID, chatID, observerUserID, observedChannelIdentityID := setupObservedChatScenario(t) - defer fixture.cleanup() - - ctx := context.Background() - beforeBind, err := fixture.chatSvc.ListByBotAndChannelIdentity(ctx, botID, observerUserID) - if err != nil { - t.Fatalf("list chats before bind failed: %v", err) - } - if len(beforeBind) != 0 { - t.Fatalf("expected no visible chats before bind, got %d", len(beforeBind)) - } - - if err := fixture.channelIdentitySvc.LinkChannelIdentityToUser(ctx, observedChannelIdentityID, observerUserID); err != nil { - t.Fatalf("link channelIdentity to user failed: %v", err) - } - - afterBind, err := fixture.chatSvc.ListByBotAndChannelIdentity(ctx, botID, observerUserID) - if err != nil { - t.Fatalf("list chats after bind failed: %v", err) - } - if len(afterBind) == 0 { - t.Fatalf("expected observed chat visible after bind, got %d chats", len(afterBind)) - } - - var target *conversation.ChatListItem - for i := range afterBind { - if afterBind[i].ID == chatID { - target = &afterBind[i] - break - } - } - if target == nil { - t.Fatalf("expected chat %s in visible list after bind", chatID) - } - if target.AccessMode != conversation.AccessModeChannelIdentityObserved { - t.Fatalf("expected access_mode=%s, got %s", conversation.AccessModeChannelIdentityObserved, target.AccessMode) - } - if target.ParticipantRole != "" { - t.Fatalf("expected empty participant_role for observed chat, got %s", target.ParticipantRole) - } - if target.LastObservedAt == nil { - t.Fatal("expected last_observed_at to be set for observed chat") - } -} - -func TestObservedAccessReadableButNotParticipant(t *testing.T) { - fixture, botID, chatID, observerUserID, observedChannelIdentityID := setupObservedChatScenario(t) - defer fixture.cleanup() - - ctx := context.Background() - if err := fixture.channelIdentitySvc.LinkChannelIdentityToUser(ctx, observedChannelIdentityID, observerUserID); err != nil { - t.Fatalf("link channelIdentity to user failed: %v", err) - } - - access, err := fixture.chatSvc.GetReadAccess(ctx, chatID, observerUserID) - if err != nil { - t.Fatalf("get read access failed: %v", err) - } - if access.AccessMode != conversation.AccessModeChannelIdentityObserved { - t.Fatalf("expected read access %s, got %s", conversation.AccessModeChannelIdentityObserved, access.AccessMode) - } - - messages, err := fixture.chatSvc.ListMessages(ctx, chatID) - if err != nil { - t.Fatalf("list messages failed: %v", err) - } - if len(messages) == 0 { - t.Fatal("expected observed user can read chat messages") - } - - _, err = fixture.chatSvc.GetParticipant(ctx, chatID, observerUserID) - if !errors.Is(err, conversation.ErrNotParticipant) { - t.Fatalf("expected ErrNotParticipant for observed user, got %v", err) - } - ok, err := fixture.chatSvc.IsParticipant(ctx, chatID, observerUserID) - if err != nil { - t.Fatalf("check participant failed: %v", err) - } - if ok { - t.Fatal("expected observed user to remain non-participant") - } - - visibleChats, err := fixture.chatSvc.ListByBotAndChannelIdentity(ctx, botID, observerUserID) - if err != nil { - t.Fatalf("list visible chats failed: %v", err) - } - if len(visibleChats) == 0 || visibleChats[0].AccessMode != conversation.AccessModeChannelIdentityObserved { - t.Fatal("expected observed list entry with channel_identity_observed access mode") - } -} diff --git a/internal/chat/types.go b/internal/chat/types.go deleted file mode 100644 index 2e203280..00000000 --- a/internal/chat/types.go +++ /dev/null @@ -1,269 +0,0 @@ -// Package conversation orchestrates interactions with the agent gateway, including -// synchronous and streaming responses, scheduled triggers, messages, and memory storage. -package conversation - -import ( - "encoding/json" - "strings" - "time" -) - -// Chat kind constants. -const ( - KindDirect = "direct" - KindGroup = "group" - KindThread = "thread" -) - -// Participant role constants. -const ( - RoleOwner = "owner" - RoleAdmin = "admin" - RoleMember = "member" -) - -// Chat list access mode constants. -const ( - AccessModeParticipant = "participant" - AccessModeChannelIdentityObserved = "channel_identity_observed" -) - -// Chat is the first-class conversation container. -type Chat struct { - ID string `json:"id"` - BotID string `json:"bot_id"` - Kind string `json:"kind"` - ParentChatID string `json:"parent_chat_id,omitempty"` - Title string `json:"title,omitempty"` - CreatedBy string `json:"created_by"` - Metadata map[string]any `json:"metadata,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` -} - -// ChatListItem is a chat entry with access context for list rendering. -type ChatListItem struct { - ID string `json:"id"` - BotID string `json:"bot_id"` - Kind string `json:"kind"` - ParentChatID string `json:"parent_chat_id,omitempty"` - Title string `json:"title,omitempty"` - CreatedBy string `json:"created_by"` - Metadata map[string]any `json:"metadata,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - AccessMode string `json:"access_mode"` - ParticipantRole string `json:"participant_role,omitempty"` - LastObservedAt *time.Time `json:"last_observed_at,omitempty"` -} - -// ChatReadAccess is the resolved access context for reading chat content. -type ChatReadAccess struct { - AccessMode string - ParticipantRole string - LastObservedAt *time.Time -} - -// Participant represents a chat member. -type Participant struct { - ChatID string `json:"chat_id"` - UserID string `json:"user_id"` - Role string `json:"role"` - JoinedAt time.Time `json:"joined_at"` -} - -// Settings holds per-chat configuration. -type Settings struct { - ChatID string `json:"chat_id"` - ModelID string `json:"model_id,omitempty"` -} - -// Route maps external channel conversations to a chat. -type Route struct { - ID string `json:"id"` - ChatID string `json:"chat_id"` - BotID string `json:"bot_id"` - Platform string `json:"platform"` - ChannelConfigID string `json:"channel_config_id,omitempty"` - ConversationID string `json:"conversation_id"` - ThreadID string `json:"thread_id,omitempty"` - ReplyTarget string `json:"reply_target,omitempty"` - Metadata map[string]any `json:"metadata,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` -} - -// Message represents a single persisted bot message. -type Message struct { - ID string `json:"id"` - BotID string `json:"bot_id"` - RouteID string `json:"route_id,omitempty"` - SenderChannelIdentityID string `json:"sender_channel_identity_id,omitempty"` - SenderUserID string `json:"sender_user_id,omitempty"` - Platform string `json:"platform,omitempty"` - ExternalMessageID string `json:"external_message_id,omitempty"` - SourceReplyToMessageID string `json:"source_reply_to_message_id,omitempty"` - Role string `json:"role"` - Content json.RawMessage `json:"content"` - Metadata map[string]any `json:"metadata,omitempty"` - CreatedAt time.Time `json:"created_at"` -} - -// CreateRequest is the input for creating a bot-scoped conversation container. -type CreateRequest struct { - Kind string `json:"kind"` - Title string `json:"title,omitempty"` - ParentChatID string `json:"parent_chat_id,omitempty"` - Metadata map[string]any `json:"metadata,omitempty"` -} - -// UpdateSettingsRequest is the input for updating chat settings. -type UpdateSettingsRequest struct { - ModelID *string `json:"model_id,omitempty"` -} - -// ResolveChatResult is returned by ResolveChat. -type ResolveChatResult struct { - ChatID string - RouteID string - Created bool -} - -// ModelMessage is the canonical message format exchanged with the agent gateway. -// Aligned with Vercel AI SDK ModelMessage structure. -type ModelMessage struct { - Role string `json:"role"` - Content json.RawMessage `json:"content,omitempty"` - ToolCalls []ToolCall `json:"tool_calls,omitempty"` - ToolCallID string `json:"tool_call_id,omitempty"` - Name string `json:"name,omitempty"` -} - -// TextContent extracts the plain text from the message content. -// If content is a string, it returns it directly. -// If content is an array of parts, it joins all text-type parts. -func (m ModelMessage) TextContent() string { - if len(m.Content) == 0 { - return "" - } - var s string - if err := json.Unmarshal(m.Content, &s); err == nil { - return s - } - var parts []ContentPart - if err := json.Unmarshal(m.Content, &parts); err == nil { - texts := make([]string, 0, len(parts)) - for _, p := range parts { - if strings.TrimSpace(p.Text) != "" { - texts = append(texts, p.Text) - } - } - return strings.Join(texts, "\n") - } - return "" -} - -// ContentParts parses the content as an array of ContentPart. -// Returns nil if the content is a plain string or not parseable. -func (m ModelMessage) ContentParts() []ContentPart { - if len(m.Content) == 0 { - return nil - } - var parts []ContentPart - if err := json.Unmarshal(m.Content, &parts); err != nil { - return nil - } - return parts -} - -// HasContent reports whether the message carries non-empty content or tool calls. -func (m ModelMessage) HasContent() bool { - if strings.TrimSpace(m.TextContent()) != "" { - return true - } - if len(m.ContentParts()) > 0 { - return true - } - return len(m.ToolCalls) > 0 -} - -// NewTextContent creates a json.RawMessage from a plain string. -func NewTextContent(text string) json.RawMessage { - data, _ := json.Marshal(text) - return data -} - -// ContentPart represents one element of a multi-part message content. -type ContentPart struct { - Type string `json:"type"` - Text string `json:"text,omitempty"` - URL string `json:"url,omitempty"` - Styles []string `json:"styles,omitempty"` - Language string `json:"language,omitempty"` - ChannelIdentityID string `json:"channel_identity_id,omitempty"` - Emoji string `json:"emoji,omitempty"` - Metadata map[string]any `json:"metadata,omitempty"` -} - -// HasValue reports whether the content part carries a meaningful value. -func (p ContentPart) HasValue() bool { - return strings.TrimSpace(p.Text) != "" || - strings.TrimSpace(p.URL) != "" || - strings.TrimSpace(p.Emoji) != "" -} - -// ToolCall represents a function/tool invocation in an assistant message. -type ToolCall struct { - ID string `json:"id,omitempty"` - Type string `json:"type"` - Function ToolCallFunction `json:"function"` -} - -// ToolCallFunction holds the name and serialized arguments of a tool call. -type ToolCallFunction struct { - Name string `json:"name"` - Arguments string `json:"arguments"` -} - -// ChatRequest is the input for Chat and StreamChat. -type ChatRequest struct { - BotID string `json:"-"` - ChatID string `json:"-"` - Token string `json:"-"` - UserID string `json:"-"` - SourceChannelIdentityID string `json:"-"` - ContainerID string `json:"-"` - DisplayName string `json:"-"` - RouteID string `json:"-"` - ChatToken string `json:"-"` - ExternalMessageID string `json:"-"` - UserMessagePersisted bool `json:"-"` - - Query string `json:"query"` - Model string `json:"model,omitempty"` - Provider string `json:"provider,omitempty"` - MaxContextLoadTime int `json:"max_context_load_time,omitempty"` - Language string `json:"language,omitempty"` - Channels []string `json:"channels,omitempty"` - CurrentChannel string `json:"current_channel,omitempty"` - Messages []ModelMessage `json:"messages,omitempty"` - Skills []string `json:"skills,omitempty"` - AllowedActions []string `json:"allowed_actions,omitempty"` -} - -// ChatResponse is the output of a non-streaming chat call. -type ChatResponse struct { - Messages []ModelMessage `json:"messages"` - Skills []string `json:"skills,omitempty"` - Model string `json:"model,omitempty"` - Provider string `json:"provider,omitempty"` -} - -// StreamChunk is a raw JSON chunk from the streaming response. -type StreamChunk = json.RawMessage - -// AssistantOutput holds extracted assistant content for downstream consumers. -type AssistantOutput struct { - Content string - Parts []ContentPart -} diff --git a/internal/conversation/flow/resolver.go b/internal/conversation/flow/resolver.go index 9ed3225d..8cd6f205 100644 --- a/internal/conversation/flow/resolver.go +++ b/internal/conversation/flow/resolver.go @@ -406,16 +406,18 @@ func (r *Resolver) StreamChat(ctx context.Context, req ChatRequest) (<-chan Stre errCh <- err return } - if err := r.persistUserMessage(ctx, streamReq); err != nil { - r.logger.Error("gateway stream persist user message failed", - slog.String("bot_id", streamReq.BotID), - slog.String("chat_id", streamReq.ChatID), - slog.Any("error", err), - ) - errCh <- err - return + if !streamReq.UserMessagePersisted { + if err := r.persistUserMessage(ctx, streamReq); err != nil { + r.logger.Error("gateway stream persist user message failed", + slog.String("bot_id", streamReq.BotID), + slog.String("chat_id", streamReq.ChatID), + slog.Any("error", err), + ) + errCh <- err + return + } + streamReq.UserMessagePersisted = true } - streamReq.UserMessagePersisted = true if err := r.streamChat(ctx, rc.payload, streamReq, chunkCh); err != nil { r.logger.Error("gateway stream request failed", slog.String("bot_id", streamReq.BotID), @@ -1128,13 +1130,11 @@ func (r *Resolver) loadBotSettings(ctx context.Context, botID string) (settings. // --- utility --- func normalizeClientType(clientType string) (string, error) { - switch strings.ToLower(strings.TrimSpace(clientType)) { - case "openai", "openai-compat": - return "openai", nil - case "anthropic": - return "anthropic", nil - case "google": - return "google", nil + ct := strings.ToLower(strings.TrimSpace(clientType)) + switch ct { + case "openai", "openai-compat", "anthropic", "google", + "azure", "bedrock", "mistral", "xai", "ollama", "dashscope": + return ct, nil default: return "", fmt.Errorf("unsupported agent gateway client type: %s", clientType) } diff --git a/internal/conversation/resolver.go b/internal/conversation/resolver.go index a78f9e2c..5bfc3fa9 100644 --- a/internal/conversation/resolver.go +++ b/internal/conversation/resolver.go @@ -1081,13 +1081,11 @@ func (r *Resolver) loadBotSettings(ctx context.Context, botID string) (settings. // --- utility --- func normalizeClientType(clientType string) (string, error) { - switch strings.ToLower(strings.TrimSpace(clientType)) { - case "openai", "openai-compat": - return "openai", nil - case "anthropic": - return "anthropic", nil - case "google": - return "google", nil + ct := strings.ToLower(strings.TrimSpace(clientType)) + switch ct { + case "openai", "openai-compat", "anthropic", "google", + "azure", "bedrock", "mistral", "xai", "ollama", "dashscope": + return ct, nil default: return "", fmt.Errorf("unsupported agent gateway client type: %s", clientType) } diff --git a/internal/conversation/service_presence_integration_test.go b/internal/conversation/service_presence_integration_test.go index fef12f72..061cc5ae 100644 --- a/internal/conversation/service_presence_integration_test.go +++ b/internal/conversation/service_presence_integration_test.go @@ -127,6 +127,7 @@ func setupObservedChatScenario(t *testing.T) (chatPresenceFixture, string, strin "feishu", fmt.Sprintf("presence-channelIdentity-%d", time.Now().UnixNano()), "presence-observer", + nil, ) if err != nil { fixture.cleanup() diff --git a/internal/db/sqlc/channel_identities.sql.go b/internal/db/sqlc/channel_identities.sql.go index b7ad5b17..e71c311a 100644 --- a/internal/db/sqlc/channel_identities.sql.go +++ b/internal/db/sqlc/channel_identities.sql.go @@ -15,7 +15,7 @@ const clearChannelIdentityLinkedUser = `-- name: ClearChannelIdentityLinkedUser UPDATE channel_identities SET user_id = NULL, updated_at = now() WHERE id = $1 -RETURNING id, user_id, channel_type, channel_subject_id, display_name, metadata, created_at, updated_at +RETURNING id, user_id, channel_type, channel_subject_id, display_name, avatar_url, metadata, created_at, updated_at ` func (q *Queries) ClearChannelIdentityLinkedUser(ctx context.Context, id pgtype.UUID) (ChannelIdentity, error) { @@ -27,6 +27,7 @@ func (q *Queries) ClearChannelIdentityLinkedUser(ctx context.Context, id pgtype. &i.ChannelType, &i.ChannelSubjectID, &i.DisplayName, + &i.AvatarUrl, &i.Metadata, &i.CreatedAt, &i.UpdatedAt, @@ -35,9 +36,9 @@ func (q *Queries) ClearChannelIdentityLinkedUser(ctx context.Context, id pgtype. } const createChannelIdentity = `-- name: CreateChannelIdentity :one -INSERT INTO channel_identities (user_id, channel_type, channel_subject_id, display_name, metadata) -VALUES ($1, $2, $3, $4, $5) -RETURNING id, user_id, channel_type, channel_subject_id, display_name, metadata, created_at, updated_at +INSERT INTO channel_identities (user_id, channel_type, channel_subject_id, display_name, avatar_url, metadata) +VALUES ($1, $2, $3, $4, $5, $6) +RETURNING id, user_id, channel_type, channel_subject_id, display_name, avatar_url, metadata, created_at, updated_at ` type CreateChannelIdentityParams struct { @@ -45,6 +46,7 @@ type CreateChannelIdentityParams struct { ChannelType string `json:"channel_type"` ChannelSubjectID string `json:"channel_subject_id"` DisplayName pgtype.Text `json:"display_name"` + AvatarUrl pgtype.Text `json:"avatar_url"` Metadata []byte `json:"metadata"` } @@ -54,6 +56,7 @@ func (q *Queries) CreateChannelIdentity(ctx context.Context, arg CreateChannelId arg.ChannelType, arg.ChannelSubjectID, arg.DisplayName, + arg.AvatarUrl, arg.Metadata, ) var i ChannelIdentity @@ -63,6 +66,7 @@ func (q *Queries) CreateChannelIdentity(ctx context.Context, arg CreateChannelId &i.ChannelType, &i.ChannelSubjectID, &i.DisplayName, + &i.AvatarUrl, &i.Metadata, &i.CreatedAt, &i.UpdatedAt, @@ -71,7 +75,7 @@ func (q *Queries) CreateChannelIdentity(ctx context.Context, arg CreateChannelId } const getChannelIdentityByChannelSubject = `-- name: GetChannelIdentityByChannelSubject :one -SELECT id, user_id, channel_type, channel_subject_id, display_name, metadata, created_at, updated_at +SELECT id, user_id, channel_type, channel_subject_id, display_name, avatar_url, metadata, created_at, updated_at FROM channel_identities WHERE channel_type = $1 AND channel_subject_id = $2 ` @@ -90,6 +94,7 @@ func (q *Queries) GetChannelIdentityByChannelSubject(ctx context.Context, arg Ge &i.ChannelType, &i.ChannelSubjectID, &i.DisplayName, + &i.AvatarUrl, &i.Metadata, &i.CreatedAt, &i.UpdatedAt, @@ -98,7 +103,7 @@ func (q *Queries) GetChannelIdentityByChannelSubject(ctx context.Context, arg Ge } const getChannelIdentityByID = `-- name: GetChannelIdentityByID :one -SELECT id, user_id, channel_type, channel_subject_id, display_name, metadata, created_at, updated_at +SELECT id, user_id, channel_type, channel_subject_id, display_name, avatar_url, metadata, created_at, updated_at FROM channel_identities WHERE id = $1 ` @@ -112,6 +117,7 @@ func (q *Queries) GetChannelIdentityByID(ctx context.Context, id pgtype.UUID) (C &i.ChannelType, &i.ChannelSubjectID, &i.DisplayName, + &i.AvatarUrl, &i.Metadata, &i.CreatedAt, &i.UpdatedAt, @@ -120,7 +126,7 @@ func (q *Queries) GetChannelIdentityByID(ctx context.Context, id pgtype.UUID) (C } const getChannelIdentityByIDForUpdate = `-- name: GetChannelIdentityByIDForUpdate :one -SELECT id, user_id, channel_type, channel_subject_id, display_name, metadata, created_at, updated_at +SELECT id, user_id, channel_type, channel_subject_id, display_name, avatar_url, metadata, created_at, updated_at FROM channel_identities WHERE id = $1 FOR UPDATE @@ -135,6 +141,7 @@ func (q *Queries) GetChannelIdentityByIDForUpdate(ctx context.Context, id pgtype &i.ChannelType, &i.ChannelSubjectID, &i.DisplayName, + &i.AvatarUrl, &i.Metadata, &i.CreatedAt, &i.UpdatedAt, @@ -143,7 +150,7 @@ func (q *Queries) GetChannelIdentityByIDForUpdate(ctx context.Context, id pgtype } const listChannelIdentitiesByUserID = `-- name: ListChannelIdentitiesByUserID :many -SELECT id, user_id, channel_type, channel_subject_id, display_name, metadata, created_at, updated_at +SELECT id, user_id, channel_type, channel_subject_id, display_name, avatar_url, metadata, created_at, updated_at FROM channel_identities WHERE user_id = $1 ORDER BY created_at DESC @@ -164,6 +171,7 @@ func (q *Queries) ListChannelIdentitiesByUserID(ctx context.Context, userID pgty &i.ChannelType, &i.ChannelSubjectID, &i.DisplayName, + &i.AvatarUrl, &i.Metadata, &i.CreatedAt, &i.UpdatedAt, @@ -182,7 +190,7 @@ const setChannelIdentityLinkedUser = `-- name: SetChannelIdentityLinkedUser :one UPDATE channel_identities SET user_id = $2, updated_at = now() WHERE id = $1 -RETURNING id, user_id, channel_type, channel_subject_id, display_name, metadata, created_at, updated_at +RETURNING id, user_id, channel_type, channel_subject_id, display_name, avatar_url, metadata, created_at, updated_at ` type SetChannelIdentityLinkedUserParams struct { @@ -199,6 +207,7 @@ func (q *Queries) SetChannelIdentityLinkedUser(ctx context.Context, arg SetChann &i.ChannelType, &i.ChannelSubjectID, &i.DisplayName, + &i.AvatarUrl, &i.Metadata, &i.CreatedAt, &i.UpdatedAt, @@ -207,15 +216,16 @@ func (q *Queries) SetChannelIdentityLinkedUser(ctx context.Context, arg SetChann } const upsertChannelIdentityByChannelSubject = `-- name: UpsertChannelIdentityByChannelSubject :one -INSERT INTO channel_identities (user_id, channel_type, channel_subject_id, display_name, metadata) -VALUES ($1, $2, $3, $4, $5) +INSERT INTO channel_identities (user_id, channel_type, channel_subject_id, display_name, avatar_url, metadata) +VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT (channel_type, channel_subject_id) DO UPDATE SET display_name = COALESCE(NULLIF(EXCLUDED.display_name, ''), channel_identities.display_name), + avatar_url = COALESCE(NULLIF(EXCLUDED.avatar_url, ''), channel_identities.avatar_url), metadata = EXCLUDED.metadata, user_id = COALESCE(channel_identities.user_id, EXCLUDED.user_id), updated_at = now() -RETURNING id, user_id, channel_type, channel_subject_id, display_name, metadata, created_at, updated_at +RETURNING id, user_id, channel_type, channel_subject_id, display_name, avatar_url, metadata, created_at, updated_at ` type UpsertChannelIdentityByChannelSubjectParams struct { @@ -223,6 +233,7 @@ type UpsertChannelIdentityByChannelSubjectParams struct { ChannelType string `json:"channel_type"` ChannelSubjectID string `json:"channel_subject_id"` DisplayName pgtype.Text `json:"display_name"` + AvatarUrl pgtype.Text `json:"avatar_url"` Metadata []byte `json:"metadata"` } @@ -232,6 +243,7 @@ func (q *Queries) UpsertChannelIdentityByChannelSubject(ctx context.Context, arg arg.ChannelType, arg.ChannelSubjectID, arg.DisplayName, + arg.AvatarUrl, arg.Metadata, ) var i ChannelIdentity @@ -241,6 +253,7 @@ func (q *Queries) UpsertChannelIdentityByChannelSubject(ctx context.Context, arg &i.ChannelType, &i.ChannelSubjectID, &i.DisplayName, + &i.AvatarUrl, &i.Metadata, &i.CreatedAt, &i.UpdatedAt, diff --git a/internal/db/sqlc/messages.sql.go b/internal/db/sqlc/messages.sql.go index dffb3037..aee38039 100644 --- a/internal/db/sqlc/messages.sql.go +++ b/internal/db/sqlc/messages.sql.go @@ -122,21 +122,24 @@ func (q *Queries) DeleteMessagesByBot(ctx context.Context, botID pgtype.UUID) er const listMessages = `-- name: ListMessages :many SELECT - id, - bot_id, - route_id, - sender_channel_identity_id, - sender_account_user_id AS sender_user_id, - channel_type AS platform, - source_message_id AS external_message_id, - source_reply_to_message_id, - role, - content, - metadata, - created_at -FROM bot_history_messages -WHERE bot_id = $1 -ORDER BY created_at ASC + m.id, + m.bot_id, + m.route_id, + m.sender_channel_identity_id, + m.sender_account_user_id AS sender_user_id, + m.channel_type AS platform, + m.source_message_id AS external_message_id, + m.source_reply_to_message_id, + m.role, + m.content, + m.metadata, + m.created_at, + ci.display_name AS sender_display_name, + ci.avatar_url AS sender_avatar_url +FROM bot_history_messages m +LEFT JOIN channel_identities ci ON ci.id = m.sender_channel_identity_id +WHERE m.bot_id = $1 +ORDER BY m.created_at ASC ` type ListMessagesRow struct { @@ -152,6 +155,8 @@ type ListMessagesRow struct { Content []byte `json:"content"` Metadata []byte `json:"metadata"` CreatedAt pgtype.Timestamptz `json:"created_at"` + SenderDisplayName pgtype.Text `json:"sender_display_name"` + SenderAvatarUrl pgtype.Text `json:"sender_avatar_url"` } func (q *Queries) ListMessages(ctx context.Context, botID pgtype.UUID) ([]ListMessagesRow, error) { @@ -176,6 +181,8 @@ func (q *Queries) ListMessages(ctx context.Context, botID pgtype.UUID) ([]ListMe &i.Content, &i.Metadata, &i.CreatedAt, + &i.SenderDisplayName, + &i.SenderAvatarUrl, ); err != nil { return nil, err } @@ -189,22 +196,25 @@ func (q *Queries) ListMessages(ctx context.Context, botID pgtype.UUID) ([]ListMe const listMessagesBefore = `-- name: ListMessagesBefore :many SELECT - id, - bot_id, - route_id, - sender_channel_identity_id, - sender_account_user_id AS sender_user_id, - channel_type AS platform, - source_message_id AS external_message_id, - source_reply_to_message_id, - role, - content, - metadata, - created_at -FROM bot_history_messages -WHERE bot_id = $1 - AND created_at < $2 -ORDER BY created_at DESC + m.id, + m.bot_id, + m.route_id, + m.sender_channel_identity_id, + m.sender_account_user_id AS sender_user_id, + m.channel_type AS platform, + m.source_message_id AS external_message_id, + m.source_reply_to_message_id, + m.role, + m.content, + m.metadata, + m.created_at, + ci.display_name AS sender_display_name, + ci.avatar_url AS sender_avatar_url +FROM bot_history_messages m +LEFT JOIN channel_identities ci ON ci.id = m.sender_channel_identity_id +WHERE m.bot_id = $1 + AND m.created_at < $2 +ORDER BY m.created_at DESC LIMIT $3 ` @@ -227,6 +237,8 @@ type ListMessagesBeforeRow struct { Content []byte `json:"content"` Metadata []byte `json:"metadata"` CreatedAt pgtype.Timestamptz `json:"created_at"` + SenderDisplayName pgtype.Text `json:"sender_display_name"` + SenderAvatarUrl pgtype.Text `json:"sender_avatar_url"` } func (q *Queries) ListMessagesBefore(ctx context.Context, arg ListMessagesBeforeParams) ([]ListMessagesBeforeRow, error) { @@ -251,6 +263,8 @@ func (q *Queries) ListMessagesBefore(ctx context.Context, arg ListMessagesBefore &i.Content, &i.Metadata, &i.CreatedAt, + &i.SenderDisplayName, + &i.SenderAvatarUrl, ); err != nil { return nil, err } @@ -264,21 +278,24 @@ func (q *Queries) ListMessagesBefore(ctx context.Context, arg ListMessagesBefore const listMessagesLatest = `-- name: ListMessagesLatest :many SELECT - id, - bot_id, - route_id, - sender_channel_identity_id, - sender_account_user_id AS sender_user_id, - channel_type AS platform, - source_message_id AS external_message_id, - source_reply_to_message_id, - role, - content, - metadata, - created_at -FROM bot_history_messages -WHERE bot_id = $1 -ORDER BY created_at DESC + m.id, + m.bot_id, + m.route_id, + m.sender_channel_identity_id, + m.sender_account_user_id AS sender_user_id, + m.channel_type AS platform, + m.source_message_id AS external_message_id, + m.source_reply_to_message_id, + m.role, + m.content, + m.metadata, + m.created_at, + ci.display_name AS sender_display_name, + ci.avatar_url AS sender_avatar_url +FROM bot_history_messages m +LEFT JOIN channel_identities ci ON ci.id = m.sender_channel_identity_id +WHERE m.bot_id = $1 +ORDER BY m.created_at DESC LIMIT $2 ` @@ -300,6 +317,8 @@ type ListMessagesLatestRow struct { Content []byte `json:"content"` Metadata []byte `json:"metadata"` CreatedAt pgtype.Timestamptz `json:"created_at"` + SenderDisplayName pgtype.Text `json:"sender_display_name"` + SenderAvatarUrl pgtype.Text `json:"sender_avatar_url"` } func (q *Queries) ListMessagesLatest(ctx context.Context, arg ListMessagesLatestParams) ([]ListMessagesLatestRow, error) { @@ -324,6 +343,8 @@ func (q *Queries) ListMessagesLatest(ctx context.Context, arg ListMessagesLatest &i.Content, &i.Metadata, &i.CreatedAt, + &i.SenderDisplayName, + &i.SenderAvatarUrl, ); err != nil { return nil, err } @@ -337,22 +358,25 @@ func (q *Queries) ListMessagesLatest(ctx context.Context, arg ListMessagesLatest const listMessagesSince = `-- name: ListMessagesSince :many SELECT - id, - bot_id, - route_id, - sender_channel_identity_id, - sender_account_user_id AS sender_user_id, - channel_type AS platform, - source_message_id AS external_message_id, - source_reply_to_message_id, - role, - content, - metadata, - created_at -FROM bot_history_messages -WHERE bot_id = $1 - AND created_at >= $2 -ORDER BY created_at ASC + m.id, + m.bot_id, + m.route_id, + m.sender_channel_identity_id, + m.sender_account_user_id AS sender_user_id, + m.channel_type AS platform, + m.source_message_id AS external_message_id, + m.source_reply_to_message_id, + m.role, + m.content, + m.metadata, + m.created_at, + ci.display_name AS sender_display_name, + ci.avatar_url AS sender_avatar_url +FROM bot_history_messages m +LEFT JOIN channel_identities ci ON ci.id = m.sender_channel_identity_id +WHERE m.bot_id = $1 + AND m.created_at >= $2 +ORDER BY m.created_at ASC ` type ListMessagesSinceParams struct { @@ -373,6 +397,8 @@ type ListMessagesSinceRow struct { Content []byte `json:"content"` Metadata []byte `json:"metadata"` CreatedAt pgtype.Timestamptz `json:"created_at"` + SenderDisplayName pgtype.Text `json:"sender_display_name"` + SenderAvatarUrl pgtype.Text `json:"sender_avatar_url"` } func (q *Queries) ListMessagesSince(ctx context.Context, arg ListMessagesSinceParams) ([]ListMessagesSinceRow, error) { @@ -397,6 +423,8 @@ func (q *Queries) ListMessagesSince(ctx context.Context, arg ListMessagesSincePa &i.Content, &i.Metadata, &i.CreatedAt, + &i.SenderDisplayName, + &i.SenderAvatarUrl, ); err != nil { return nil, err } diff --git a/internal/db/sqlc/models.go b/internal/db/sqlc/models.go index fff73aea..21a148f2 100644 --- a/internal/db/sqlc/models.go +++ b/internal/db/sqlc/models.go @@ -93,6 +93,7 @@ type ChannelIdentity struct { ChannelType string `json:"channel_type"` ChannelSubjectID string `json:"channel_subject_id"` DisplayName pgtype.Text `json:"display_name"` + AvatarUrl pgtype.Text `json:"avatar_url"` Metadata []byte `json:"metadata"` CreatedAt pgtype.Timestamptz `json:"created_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"` diff --git a/internal/handlers/message.go b/internal/handlers/message.go index 31c25874..160f99ab 100644 --- a/internal/handlers/message.go +++ b/internal/handlers/message.go @@ -472,7 +472,7 @@ func (h *MessageHandler) resolveWebChannelIdentity(ctx context.Context, userID s } } } - ci, err := h.channelIdentitySvc.ResolveByChannelIdentity(ctx, "web", userID, displayName) + ci, err := h.channelIdentitySvc.ResolveByChannelIdentity(ctx, "web", userID, displayName, nil) if err != nil { return userID } diff --git a/internal/handlers/models.go b/internal/handlers/models.go index 0dbd8af4..a50f8831 100644 --- a/internal/handlers/models.go +++ b/internal/handlers/models.go @@ -68,7 +68,7 @@ func (h *ModelsHandler) Create(c echo.Context) error { // @Description Get a list of all configured models, optionally filtered by type or client type // @Tags models // @Param type query string false "Model type (chat, embedding)" -// @Param client_type query string false "Client type (openai, anthropic, google)" +// @Param client_type query string false "Client type (openai, openai-compat, anthropic, google, azure, bedrock, mistral, xai, ollama, dashscope)" // @Success 200 {array} models.GetResponse // @Failure 400 {object} ErrorResponse // @Failure 500 {object} ErrorResponse diff --git a/internal/handlers/providers.go b/internal/handlers/providers.go index d35c6425..22864ac1 100644 --- a/internal/handlers/providers.go +++ b/internal/handlers/providers.go @@ -79,7 +79,7 @@ func (h *ProvidersHandler) Create(c echo.Context) error { // @Tags providers // @Accept json // @Produce json -// @Param client_type query string false "Client type filter (openai, anthropic, google, ollama)" +// @Param client_type query string false "Client type filter (openai, openai-compat, anthropic, google, azure, bedrock, mistral, xai, ollama, dashscope)" // @Success 200 {array} providers.GetResponse // @Failure 400 {object} ErrorResponse // @Failure 500 {object} ErrorResponse @@ -256,7 +256,7 @@ func (h *ProvidersHandler) Delete(c echo.Context) error { // @Tags providers // @Accept json // @Produce json -// @Param client_type query string false "Client type filter (openai, anthropic, google, ollama)" +// @Param client_type query string false "Client type filter (openai, openai-compat, anthropic, google, azure, bedrock, mistral, xai, ollama, dashscope)" // @Success 200 {object} providers.CountResponse // @Failure 400 {object} ErrorResponse // @Failure 500 {object} ErrorResponse diff --git a/internal/message/service.go b/internal/message/service.go index 60051230..0fb53eca 100644 --- a/internal/message/service.go +++ b/internal/message/service.go @@ -167,6 +167,8 @@ func toMessageFromCreate(row sqlc.CreateMessageRow) Message { row.RouteID, row.SenderChannelIdentityID, row.SenderUserID, + pgtype.Text{}, + pgtype.Text{}, row.Platform, row.ExternalMessageID, row.SourceReplyToMessageID, @@ -184,6 +186,8 @@ func toMessageFromListRow(row sqlc.ListMessagesRow) Message { row.RouteID, row.SenderChannelIdentityID, row.SenderUserID, + row.SenderDisplayName, + row.SenderAvatarUrl, row.Platform, row.ExternalMessageID, row.SourceReplyToMessageID, @@ -201,6 +205,8 @@ func toMessageFromSinceRow(row sqlc.ListMessagesSinceRow) Message { row.RouteID, row.SenderChannelIdentityID, row.SenderUserID, + row.SenderDisplayName, + row.SenderAvatarUrl, row.Platform, row.ExternalMessageID, row.SourceReplyToMessageID, @@ -218,6 +224,8 @@ func toMessageFromLatestRow(row sqlc.ListMessagesLatestRow) Message { row.RouteID, row.SenderChannelIdentityID, row.SenderUserID, + row.SenderDisplayName, + row.SenderAvatarUrl, row.Platform, row.ExternalMessageID, row.SourceReplyToMessageID, @@ -234,6 +242,8 @@ func toMessageFields( routeID pgtype.UUID, senderChannelIdentityID pgtype.UUID, senderUserID pgtype.UUID, + senderDisplayName pgtype.Text, + senderAvatarURL pgtype.Text, platform pgtype.Text, externalMessageID pgtype.Text, sourceReplyToMessageID pgtype.Text, @@ -248,6 +258,8 @@ func toMessageFields( RouteID: routeID.String(), SenderChannelIdentityID: senderChannelIdentityID.String(), SenderUserID: senderUserID.String(), + SenderDisplayName: dbpkg.TextToString(senderDisplayName), + SenderAvatarURL: dbpkg.TextToString(senderAvatarURL), Platform: dbpkg.TextToString(platform), ExternalMessageID: dbpkg.TextToString(externalMessageID), SourceReplyToMessageID: dbpkg.TextToString(sourceReplyToMessageID), @@ -289,6 +301,8 @@ func toMessageFromBeforeRow(row sqlc.ListMessagesBeforeRow) Message { row.RouteID, row.SenderChannelIdentityID, row.SenderUserID, + row.SenderDisplayName, + row.SenderAvatarUrl, row.Platform, row.ExternalMessageID, row.SourceReplyToMessageID, diff --git a/internal/message/types.go b/internal/message/types.go index f5938474..28225238 100644 --- a/internal/message/types.go +++ b/internal/message/types.go @@ -13,6 +13,8 @@ type Message struct { RouteID string `json:"route_id,omitempty"` SenderChannelIdentityID string `json:"sender_channel_identity_id,omitempty"` SenderUserID string `json:"sender_user_id,omitempty"` + SenderDisplayName string `json:"sender_display_name,omitempty"` + SenderAvatarURL string `json:"sender_avatar_url,omitempty"` Platform string `json:"platform,omitempty"` ExternalMessageID string `json:"external_message_id,omitempty"` SourceReplyToMessageID string `json:"source_reply_to_message_id,omitempty"` diff --git a/internal/models/models.go b/internal/models/models.go index e648c8f0..b576c15e 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -357,13 +357,15 @@ func modelInputFromMultimodal(isMultimodal bool) []string { func isValidClientType(clientType ClientType) bool { switch clientType { case ClientTypeOpenAI, + ClientTypeOpenAICompat, ClientTypeAnthropic, ClientTypeGoogle, - ClientTypeBedrock, - ClientTypeOllama, ClientTypeAzure, - ClientTypeDashscope, - ClientTypeOther: + ClientTypeBedrock, + ClientTypeMistral, + ClientTypeXAI, + ClientTypeOllama, + ClientTypeDashscope: return true default: return false diff --git a/internal/models/models_test.go b/internal/models/models_test.go index 35fe4b31..429fde45 100644 --- a/internal/models/models_test.go +++ b/internal/models/models_test.go @@ -191,13 +191,15 @@ func TestModelTypes(t *testing.T) { t.Run("ClientType constants", func(t *testing.T) { assert.Equal(t, models.ClientType("openai"), models.ClientTypeOpenAI) + assert.Equal(t, models.ClientType("openai-compat"), models.ClientTypeOpenAICompat) assert.Equal(t, models.ClientType("anthropic"), models.ClientTypeAnthropic) assert.Equal(t, models.ClientType("google"), models.ClientTypeGoogle) - assert.Equal(t, models.ClientType("bedrock"), models.ClientTypeBedrock) - assert.Equal(t, models.ClientType("ollama"), models.ClientTypeOllama) assert.Equal(t, models.ClientType("azure"), models.ClientTypeAzure) + assert.Equal(t, models.ClientType("bedrock"), models.ClientTypeBedrock) + assert.Equal(t, models.ClientType("mistral"), models.ClientTypeMistral) + assert.Equal(t, models.ClientType("xai"), models.ClientTypeXAI) + assert.Equal(t, models.ClientType("ollama"), models.ClientTypeOllama) assert.Equal(t, models.ClientType("dashscope"), models.ClientTypeDashscope) - assert.Equal(t, models.ClientType("other"), models.ClientTypeOther) }) } diff --git a/internal/models/types.go b/internal/models/types.go index c0ef4df2..2fd9b805 100644 --- a/internal/models/types.go +++ b/internal/models/types.go @@ -21,14 +21,16 @@ const ( type ClientType string const ( - ClientTypeOpenAI ClientType = "openai" - ClientTypeAnthropic ClientType = "anthropic" - ClientTypeGoogle ClientType = "google" - ClientTypeBedrock ClientType = "bedrock" - ClientTypeOllama ClientType = "ollama" - ClientTypeAzure ClientType = "azure" - ClientTypeDashscope ClientType = "dashscope" - ClientTypeOther ClientType = "other" + ClientTypeOpenAI ClientType = "openai" + ClientTypeOpenAICompat ClientType = "openai-compat" + ClientTypeAnthropic ClientType = "anthropic" + ClientTypeGoogle ClientType = "google" + ClientTypeAzure ClientType = "azure" + ClientTypeBedrock ClientType = "bedrock" + ClientTypeMistral ClientType = "mistral" + ClientTypeXAI ClientType = "xai" + ClientTypeOllama ClientType = "ollama" + ClientTypeDashscope ClientType = "dashscope" ) type Model struct { diff --git a/internal/providers/service.go b/internal/providers/service.go index dba23335..a57d661b 100644 --- a/internal/providers/service.go +++ b/internal/providers/service.go @@ -232,7 +232,9 @@ func (s *Service) toGetResponse(provider sqlc.LlmProvider) GetResponse { // isValidClientType checks if a client type is valid func isValidClientType(clientType ClientType) bool { switch clientType { - case ClientTypeOpenAI, ClientTypeOpenAICompat, ClientTypeAnthropic, ClientTypeGoogle, ClientTypeOllama: + case ClientTypeOpenAI, ClientTypeOpenAICompat, ClientTypeAnthropic, ClientTypeGoogle, + ClientTypeAzure, ClientTypeBedrock, ClientTypeMistral, ClientTypeXAI, + ClientTypeOllama, ClientTypeDashscope: return true default: return false diff --git a/internal/providers/types.go b/internal/providers/types.go index 4eec664e..1af60fca 100644 --- a/internal/providers/types.go +++ b/internal/providers/types.go @@ -10,7 +10,12 @@ const ( ClientTypeOpenAICompat ClientType = "openai-compat" ClientTypeAnthropic ClientType = "anthropic" ClientTypeGoogle ClientType = "google" + ClientTypeAzure ClientType = "azure" + ClientTypeBedrock ClientType = "bedrock" + ClientTypeMistral ClientType = "mistral" + ClientTypeXAI ClientType = "xai" ClientTypeOllama ClientType = "ollama" + ClientTypeDashscope ClientType = "dashscope" ) // CreateRequest represents a request to create a new LLM provider diff --git a/internal/router/identity.go b/internal/router/identity.go index ae1995e4..0de12430 100644 --- a/internal/router/identity.go +++ b/internal/router/identity.go @@ -28,6 +28,7 @@ type InboundIdentity struct { ChannelIdentityID string UserID string DisplayName string + AvatarURL string ForceReply bool } @@ -59,7 +60,7 @@ func IdentityStateFromContext(ctx context.Context) (IdentityState, bool) { // ChannelIdentityService is the minimal interface for channel identity resolution. type ChannelIdentityService interface { - ResolveByChannelIdentity(ctx context.Context, channel, channelSubjectID, displayName string) (identities.ChannelIdentity, error) + ResolveByChannelIdentity(ctx context.Context, channel, channelSubjectID, displayName string, meta map[string]any) (identities.ChannelIdentity, error) Canonicalize(ctx context.Context, channelIdentityID string) (string, error) GetLinkedUserID(ctx context.Context, channelIdentityID string) (string, error) LinkChannelIdentityToUser(ctx context.Context, channelIdentityID, userID string) error @@ -169,7 +170,7 @@ func (r *IdentityResolver) Resolve(ctx context.Context, cfg channel.ChannelConfi channelConfigID = "" } subjectID := extractSubjectIdentity(msg) - displayName := r.resolveDisplayName(ctx, cfg, msg, subjectID) + displayName, avatarURL := r.resolveProfile(ctx, cfg, msg, subjectID) state := IdentityState{ Identity: InboundIdentity{ @@ -184,7 +185,7 @@ func (r *IdentityResolver) Resolve(ctx context.Context, cfg channel.ChannelConfi return state, fmt.Errorf("cannot resolve identity: no channel_subject_id") } - channelIdentityID, linkedUserID, err := r.resolveIdentityWithLinkedUser(ctx, msg, subjectID, displayName) + channelIdentityID, linkedUserID, err := r.resolveIdentityWithLinkedUser(ctx, msg, subjectID, displayName, avatarURL) if err != nil { return state, err } @@ -194,6 +195,7 @@ func (r *IdentityResolver) Resolve(ctx context.Context, cfg channel.ChannelConfi state.Identity.UserID = r.tryLinkConfiglessChannelIdentityToUser(ctx, msg, channelIdentityID) } state.Identity.DisplayName = displayName + state.Identity.AvatarURL = avatarURL // Bind code check runs before membership/guest checks so linking is always reachable. if handled, decision, newUserID, err := r.tryHandleBindCode(ctx, msg, channelIdentityID, subjectID); handled { @@ -282,15 +284,20 @@ func (r *IdentityResolver) Resolve(ctx context.Context, cfg channel.ChannelConfi return state, nil } -func (r *IdentityResolver) resolveIdentityWithLinkedUser(ctx context.Context, msg channel.InboundMessage, primarySubjectID, displayName string) (string, string, error) { +func (r *IdentityResolver) resolveIdentityWithLinkedUser(ctx context.Context, msg channel.InboundMessage, primarySubjectID, displayName, avatarURL string) (string, string, error) { candidates := identitySubjectCandidates(msg, primarySubjectID) if len(candidates) == 0 { return "", "", fmt.Errorf("cannot resolve identity: no channel_subject_id") } + var meta map[string]any + if strings.TrimSpace(avatarURL) != "" { + meta = map[string]any{"avatar_url": strings.TrimSpace(avatarURL)} + } + firstChannelIdentityID := "" for _, subjectID := range candidates { - channelIdentity, err := r.channelIdentities.ResolveByChannelIdentity(ctx, msg.Channel.String(), subjectID, displayName) + channelIdentity, err := r.channelIdentities.ResolveByChannelIdentity(ctx, msg.Channel.String(), subjectID, displayName, meta) if err != nil { return "", "", fmt.Errorf("resolve channel identity: %w", err) } @@ -471,25 +478,29 @@ func extractDisplayName(msg channel.InboundMessage) string { return "" } -func (r *IdentityResolver) resolveDisplayName(ctx context.Context, cfg channel.ChannelConfig, msg channel.InboundMessage, subjectID string) string { +// resolveProfile resolves display name and avatar URL for the sender. +// Always queries directory for avatar; prefers message-level display name over directory name. +func (r *IdentityResolver) resolveProfile(ctx context.Context, cfg channel.ChannelConfig, msg channel.InboundMessage, subjectID string) (string, string) { displayName := extractDisplayName(msg) - if displayName != "" { - return displayName + dirName, avatarURL := r.resolveProfileFromDirectory(ctx, cfg, msg, subjectID) + if displayName == "" { + displayName = dirName } - return r.resolveDisplayNameFromDirectory(ctx, cfg, msg, subjectID) + return displayName, avatarURL } -func (r *IdentityResolver) resolveDisplayNameFromDirectory(ctx context.Context, cfg channel.ChannelConfig, msg channel.InboundMessage, subjectID string) string { +// resolveProfileFromDirectory looks up the directory for sender display name and avatar URL. +func (r *IdentityResolver) resolveProfileFromDirectory(ctx context.Context, cfg channel.ChannelConfig, msg channel.InboundMessage, subjectID string) (string, string) { if r.registry == nil { - return "" + return "", "" } subjectID = strings.TrimSpace(subjectID) if subjectID == "" { - return "" + return "", "" } directoryAdapter, ok := r.registry.DirectoryAdapter(msg.Channel) if !ok || directoryAdapter == nil { - return "" + return "", "" } if ctx == nil { ctx = context.Background() @@ -500,21 +511,19 @@ func (r *IdentityResolver) resolveDisplayNameFromDirectory(ctx context.Context, if err != nil { if r.logger != nil { r.logger.Debug( - "resolve display name from directory failed", + "resolve profile from directory failed", slog.String("channel", msg.Channel.String()), slog.String("subject_id", subjectID), slog.Any("error", err), ) } - return "" + return "", "" } - if name := strings.TrimSpace(entry.Name); name != "" { - return name + name := strings.TrimSpace(entry.Name) + if name == "" { + name = strings.TrimSpace(entry.Handle) } - if handle := strings.TrimSpace(entry.Handle); handle != "" { - return handle - } - return "" + return name, strings.TrimSpace(entry.AvatarURL) } func extractThreadID(msg channel.InboundMessage) string { diff --git a/internal/router/identity_test.go b/internal/router/identity_test.go index 843ba72e..e141decf 100644 --- a/internal/router/identity_test.go +++ b/internal/router/identity_test.go @@ -21,11 +21,13 @@ type fakeChannelIdentityService struct { linked map[string]string calls int lastDisplayName string + lastMeta map[string]any } -func (f *fakeChannelIdentityService) ResolveByChannelIdentity(ctx context.Context, platform, externalID, displayName string) (identities.ChannelIdentity, error) { +func (f *fakeChannelIdentityService) ResolveByChannelIdentity(ctx context.Context, platform, externalID, displayName string, meta map[string]any) (identities.ChannelIdentity, error) { f.calls++ f.lastDisplayName = displayName + f.lastMeta = meta if f.err != nil { return identities.ChannelIdentity{}, f.err } @@ -311,6 +313,55 @@ func TestIdentityResolverDirectoryLookupFailureDoesNotFallbackToOpenID(t *testin } } +func TestIdentityResolverDirectoryAvatarURLPropagated(t *testing.T) { + registry := channel.NewRegistry() + directoryAdapter := &fakeDirectoryAdapter{ + channelType: channel.ChannelType("feishu"), + resolveFn: func(ctx context.Context, cfg channel.ChannelConfig, input string, kind channel.DirectoryEntryKind) (channel.DirectoryEntry, error) { + return channel.DirectoryEntry{ + Kind: channel.DirectoryEntryUser, + Name: "Avatar User", + AvatarURL: "https://example.com/avatar.png", + }, nil + }, + } + if err := registry.Register(directoryAdapter); err != nil { + t.Fatalf("register directory adapter failed: %v", err) + } + + channelIdentitySvc := &fakeChannelIdentityService{channelIdentity: identities.ChannelIdentity{ID: "channelIdentity-avatar"}} + memberSvc := &fakeMemberService{isMember: true} + policySvc := &fakePolicyService{allow: false, botType: "public"} + resolver := NewIdentityResolver(slog.Default(), registry, channelIdentitySvc, memberSvc, policySvc, nil, nil, "", "") + + msg := channel.InboundMessage{ + BotID: "bot-1", + Channel: channel.ChannelType("feishu"), + Message: channel.Message{Text: "hello"}, + ReplyTarget: "target-id", + Sender: channel.Identity{ + SubjectID: "ou-avatar", + Attributes: map[string]string{"open_id": "ou-avatar"}, + }, + } + state, err := resolver.Resolve(context.Background(), channel.ChannelConfig{BotID: "bot-1", ChannelType: channel.ChannelType("feishu")}, msg) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if state.Identity.DisplayName != "Avatar User" { + t.Fatalf("expected display name Avatar User, got %q", state.Identity.DisplayName) + } + if state.Identity.AvatarURL != "https://example.com/avatar.png" { + t.Fatalf("expected avatar url, got %q", state.Identity.AvatarURL) + } + if channelIdentitySvc.lastMeta == nil { + t.Fatal("expected metadata with avatar_url to be passed to channel identity service") + } + if channelIdentitySvc.lastMeta["avatar_url"] != "https://example.com/avatar.png" { + t.Fatalf("expected avatar_url in meta, got %v", channelIdentitySvc.lastMeta) + } +} + func TestIdentityResolverExistingMemberPasses(t *testing.T) { channelIdentitySvc := &fakeChannelIdentityService{channelIdentity: identities.ChannelIdentity{ID: "channelIdentity-2"}} memberSvc := &fakeMemberService{isMember: true} diff --git a/package.json b/package.json index bb2a4ca4..3601bf0e 100644 --- a/package.json +++ b/package.json @@ -9,9 +9,9 @@ "docs:dev": "pnpm --filter @memoh/docs dev", "docs:build": "pnpm --filter @memoh/docs build", "docs:preview": "pnpm --filter @memoh/docs preview", - "agent:dev": "pnpm --filter @memoh/agent-gateway dev", - "agent:build": "pnpm --filter @memoh/agent-gateway build", - "agent:start": "pnpm --filter @memoh/agent-gateway start", + "agent:dev": "go run ./agent-go/cmd/agent_gateway", + "agent:build": "go build ./agent-go/cmd/agent_gateway", + "agent:start": "go run ./agent-go/cmd/agent_gateway", "generate-sdk": "openapi-ts", "lint": "eslint .", "lint:fix": "eslint . --fix", diff --git a/packages/cli/src/cli/index.ts b/packages/cli/src/cli/index.ts index 4429f55d..d8d918fc 100755 --- a/packages/cli/src/cli/index.ts +++ b/packages/cli/src/cli/index.ts @@ -395,7 +395,7 @@ provider type: 'list', name: 'client_type', message: 'Client type:', - choices: ['openai', 'anthropic', 'google', 'ollama'], + choices: ['openai', 'openai-compat', 'anthropic', 'google', 'azure', 'bedrock', 'mistral', 'xai', 'ollama', 'dashscope'], }) } if (!opts.base_url) questions.push({ type: 'input', name: 'base_url', message: 'Base URL:' }) diff --git a/packages/sdk/src/types.gen.ts b/packages/sdk/src/types.gen.ts index 53952883..30522691 100644 --- a/packages/sdk/src/types.gen.ts +++ b/packages/sdk/src/types.gen.ts @@ -474,6 +474,7 @@ export type HandlersSkillsOpResponse = { }; export type IdentitiesChannelIdentity = { + avatar_url?: string; channel?: string; channel_subject_id?: string; created_at?: string; @@ -540,7 +541,7 @@ export type ModelsUpdateRequest = { type?: ModelsModelType; }; -export type ProvidersClientType = 'openai' | 'openai-compat' | 'anthropic' | 'google' | 'ollama'; +export type ProvidersClientType = 'openai' | 'openai-compat' | 'anthropic' | 'google' | 'azure' | 'bedrock' | 'mistral' | 'xai' | 'ollama' | 'dashscope'; export type ProvidersCountResponse = { count?: number; @@ -2827,7 +2828,7 @@ export type GetModelsData = { */ type?: string; /** - * Client type (openai, anthropic, google) + * Client type (openai, openai-compat, anthropic, google, azure, bedrock, mistral, xai, ollama, dashscope) */ client_type?: string; }; @@ -3193,7 +3194,7 @@ export type GetProvidersData = { path?: never; query?: { /** - * Client type filter (openai, anthropic, google, ollama) + * Client type filter (openai, openai-compat, anthropic, google, azure, bedrock, mistral, xai, ollama, dashscope) */ client_type?: string; }; @@ -3259,7 +3260,7 @@ export type GetProvidersCountData = { path?: never; query?: { /** - * Client type filter (openai, anthropic, google, ollama) + * Client type filter (openai, openai-compat, anthropic, google, azure, bedrock, mistral, xai, ollama, dashscope) */ client_type?: string; }; diff --git a/packages/shared/src/chatInfo.ts b/packages/shared/src/chatInfo.ts index 3a11d13b..5cc7ebcb 100644 --- a/packages/shared/src/chatInfo.ts +++ b/packages/shared/src/chatInfo.ts @@ -4,12 +4,17 @@ export interface robot{ id: string | number, type: string, action: 'robot', - state:'thinking'|'generate'|'complete' + state:'thinking'|'generate'|'complete', + platform?: string } export interface user{ description: string, time: Date, id: number | string, - action:'user' + action:'user', + platform?: string, + senderDisplayName?: string, + senderAvatarUrl?: string, + isSelf?: boolean } \ No newline at end of file diff --git a/packages/shared/src/model.ts b/packages/shared/src/model.ts index b48c52e0..c98beea4 100644 --- a/packages/shared/src/model.ts +++ b/packages/shared/src/model.ts @@ -1,7 +1,14 @@ export enum ModelClientType { OPENAI = 'openai', + OPENAI_COMPAT = 'openai-compat', ANTHROPIC = 'anthropic', GOOGLE = 'google', + AZURE = 'azure', + BEDROCK = 'bedrock', + MISTRAL = 'mistral', + XAI = 'xai', + OLLAMA = 'ollama', + DASHSCOPE = 'dashscope', } export enum ModelType { @@ -69,7 +76,7 @@ export type Model = EmbeddingModel | ChatModel export interface ModelList { apiKey: string, baseUrl: string, - clientType: 'OpenAI' | 'Anthropic' | 'Google', + clientType: 'OpenAI' | 'OpenAI-Compat' | 'Anthropic' | 'Google' | 'Azure' | 'Bedrock' | 'Mistral' | 'xAI' | 'Ollama' | 'DashScope', modelId: string, name: string, type: 'chat' | 'embedding', @@ -98,4 +105,7 @@ export interface ModelInfo{ enable_as?:string } -export const clientType = ['openai', 'anthropic', 'google', 'ollama'] as const +export const clientType = [ + 'openai', 'openai-compat', 'anthropic', 'google', + 'azure', 'bedrock', 'mistral', 'xai', 'ollama', 'dashscope', +] as const diff --git a/packages/web/package.json b/packages/web/package.json index 58a831d4..cb987073 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -15,7 +15,6 @@ "@fortawesome/free-solid-svg-icons": "^7.0.0", "@fortawesome/vue-fontawesome": "^3.1.1", "@memoh/sdk": "workspace:*", - "@memoh/shared": "workspace:*", "@memoh/ui": "workspace:*", "@pinia/colada": "^0.21.1", "@tailwindcss/vite": "^4.1.18", diff --git a/packages/web/src/components/Sidebar/index.vue b/packages/web/src/components/Sidebar/index.vue index 09b6bee3..c9d71c8b 100644 --- a/packages/web/src/components/Sidebar/index.vue +++ b/packages/web/src/components/Sidebar/index.vue @@ -1,62 +1,78 @@