Compare commits

...

53 Commits

Author SHA1 Message Date
Hyu cb6c8d1eb6 chore(release): bump version to 4.10.6 (#2343)
Co-authored-by: dadachann <dadachann@users.noreply.github.com>
2026-07-16 21:56:54 +08:00
Hyu 48952206db fix(lark): keep connection lookup off event loop
Move the Lark SDK synchronous connection URL lookup off the main asyncio loop, serialize reconnects, and cover the incident with a non-blocking regression test.
2026-07-15 19:53:48 +08:00
hedging8563 e5f0ffd960 feat(provider): add TokenLab requester (#2326) 2026-07-15 18:10:05 +08:00
advancer-young c7720b126d fix(mcp): reconnect session when timeout (#2340) (#2340)
Co-authored-by: yang.xiang <yang.xiang@advancegroup.com>
2026-07-15 18:09:47 +08:00
advancer-young 1d15798e5f feat(lark): support render markdown table in lark message (#2338) (#2338)
Co-authored-by: yang.xiang <yang.xiang@advancegroup.com>
2026-07-15 18:09:16 +08:00
WierJZ bd87bb453c fix(dingtalk): avoid blocking event loop on connection open (#2337) 2026-07-15 18:04:42 +08:00
Hyu 0eb1bf684a docs(readme): reorder demo and remove star history (#2341)
Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
2026-07-15 14:43:18 +08:00
DongXiaoming c53b800267 fix(provider): strip think tags for MiniMax-M3 and other OpenAI-compatible models (#2330)
* fix(provider): strip think tags for MiniMax-M3 and other OpenAI-compatible models

MiniMax-M3 (and other OpenAI-compatible providers) emit chain-of-thought
reasoning directly in the content field wrapped in  tags, instead
of using a separate reasoning_content field or the legacy CRETIRE_REASONING
markers. The existing remove_think logic only handled CRETIRE_* tags, so
think blocks leaked into user-visible output even when remove_think was enabled.

- Add _ThinkStripState: a stateful filter that correctly handles  tags
  split across streaming chunk boundaries.
- Add _strip_think classmethod with regex patterns for both  and
  CRETIRE_* tags.
- Wire think_state into invoke_llm_stream so deltas are filtered before
  reaching the accumulator.
- Add remove_think safety net in _StreamAccumulator so the final message
  from tool-call rounds also gets stripped.
- Fix remove_think resolution to use defensive nested .get() so
  pipelines missing output.misc don't raise AttributeError.

* fix(litellmchat): add missing _CLOSE_TAG class attribute on _ThinkStripState

* fix(provider): handle think stripping across LiteLLM paths

---------

Co-authored-by: WangCham <651122857@qq.com>
2026-07-15 10:54:41 +08:00
RockChinQ bd35b793e1 fix(embed): isolate browser chat sessions (#2335)
* fix(embed): isolate browser chat sessions

* fix(embed): harden reset and reconnect recovery
2026-07-13 15:08:45 +08:00
Stevenqin 2c3e52c16c fix(aiocqhttp): correct listener lifecycle handling (#2336)
* fix(aiocqhttp): correct listener lifecycle handling

* docs(aiocqhttp): add listener lifecycle evidence
2026-07-13 13:50:08 +08:00
Dongchuan Fu 0755beebcd feat: add supports for dify hitl (#2226)
* feat: Implement workflow form handling for paused workflows

- Added module-level storage for pending forms to manage state across sessions.
- Introduced functions to set, get, and clear pending forms with expiration handling.
- Enhanced DifyServiceAPIRunner to support resuming paused workflows via form actions.
- Implemented logic to yield human input requests and display appropriate messages.
- Updated workflow submission methods to handle paused states and resume actions.
- Ensured proper merging of pending form actions with user inputs for seamless interaction.

* feat: Add '_routed_by_rule' variable to form action in Lark and Telegram adapters

* feat: Enhance Lark and Telegram adapters with new form handling for paused workflows

* feat: Enhance TelegramAdapter to handle form action buttons and message threading

* feat: Improve TelegramAdapter message handling with enhanced error management and draft message support

* feat: Add the function for formatting human input text to support adapters without rich UI.

* feat(dingtalk): implement human input card support and card action handling

- Add a new module `card_callback.py` to handle card action button clicks from DingTalk.
- Introduce `DingTalkCardActionHandler` to process card action callbacks and extract parameters.
- Update `DingTalkAdapter` to manage card state and handle form input through a single card template.
- Add configuration for `human_input_card_template_id` in `dingtalk.yaml` to specify the template for human input.
- Create a new card template `dingtalk_human_input_card.json` for rendering human input prompts and buttons.

* feat(dingtalk): enhance human input card functionality with streaming support and active turn management

- Updated the DingTalk card template to enable streaming mode and multi-update configuration.
- Removed the obsolete delete_card method from DingTalkClient to streamline card management.
- Enhanced DingTalkAdapter to manage active turn cards and accumulated streaming text, ensuring a seamless user experience during human input prompts.
- Modified the create_message_card method to utilize existing active cards for resumed workflows, preventing duplication.
- Improved the _paint_form_on_card method to update existing cards with human input prompts and buttons dynamically.
- Updated the dingtalk_human_input_card.json template to reflect the new streaming capabilities and configuration options.

* feat(wecom): implement Dify human input pause handling with button interaction support

* feat(qqofficial): implement Dify human input button interaction handling and markdown keyboard support

* feat(qqofficial): implement one-click QR binding and enhance localization support

* feat(discord): implement Discord form view with button interactions for Dify actions

* fix(telegram): correct group chat type check and handle oversized callback data for Telegram actions
fix(difysvapi): ensure safe access to remove-think configuration in pipeline settings

* feat(dify): add support for chatflow app type and enhance human input handling

* feat(telegram): add action title feedback for user selections in Telegram messages

* feat(lark): enhance LarkAdapter to store form content for resume notices

* feat(dingtalk): update display formatting for card content with HTML line breaks

* feat(dingtalk): add feedback functionality to cards with 👍/👎 buttons

- Implemented feedback state management for cards, allowing users to provide feedback via thumbs up/down buttons.
- Enhanced card rendering to include feedback buttons when appropriate.
- Registered feedback listeners to handle feedback events and update card states accordingly.
- Updated the card template to support dynamic button rendering for feedback actions.
- Improved error handling and logging for feedback actions and card updates.

* fix: add Avatar component to dingtalk_human_input_card.json for enhanced user interaction

* feat(wecom): add optional source block to interactive template cards for enhanced branding

* feat(wecom): add functions for template card action extraction and update, enhance button interaction handling

* feat(qqofficial): synchronize passive-reply counter with inbound message sequence

* feat(qqofficial): add method to identify invisible form placeholder chunks in messages

* feat(dingtalk): add download link for human input card template and enhance dynamic form configuration

* feat(telegram): enhance message handling with group stream deletion and form placeholder detection

* Add unit tests for DingTalk, Lark, WeComBot, and Dify service API runners

- Implement tests for DingTalk adapter helper functions including form content cleaning, input extraction, and completed input lines.
- Create unit tests for Lark adapter helper functions focusing on input extraction and completed input lines.
- Add tests for WeComBot template card functionalities, including event extraction and payload building for human input.
- Enhance Dify service API runner tests to cover human input forms, including input collection, action handling, and form snapshot extraction.

* feat: Enhance Telegram and QQ Official adapters with select field handling and form action processing

- Added support for select fields in Telegram adapter, including option extraction and callback handling.
- Implemented form action processing for Telegram callbacks, improving user interaction feedback.
- Introduced new helper functions for building keyboards and resolving select button actions in QQ Official adapter.
- Enhanced DifyServiceAPIRunner to handle cumulative streaming responses and improve error handling during workflow resumes.
- Added unit tests for new functionalities in Telegram and QQ Official adapters, ensuring robust behavior for select fields and form actions.

* feat(lark): add functions for current input definitions and visible form content handling
feat(qqofficial): update fallback text handling for non-streaming scenarios
feat(difysvapi): enhance form content processing for interactive fields and actions
test: add unit tests for Lark and QQ Official adapter functionalities

* Add tests for DingTalk adapter content processing and markdown formatting

- Updated the assertion in `test_dingtalk_completed_input_lines_include_text_and_select_values` to remove unnecessary markdown formatting.
- Added new tests to verify that `_dingtalk_clean_form_content` maintains the order of prompts and completed values in various scenarios.
- Introduced `test_dingtalk_card_markdown_preserves_internal_line_breaks` to ensure internal line breaks are correctly converted to HTML line breaks.

* feat: Refactor input handling and feedback messages across multiple adapters

* feat: Update the human-computer interaction template cards, and optimize the prompt information and content display.

* feat: Refactor pending form handling to isolate by bot and pipeline

* feat: Enhance error handling and caching for Dify and WeCom interactions

* feat: Enhance select input handling and validation in Dify API runner and Telegram adapter

* feat: Add missing completed input lines handling in DingTalk adapter

* feat: Add pipeline_uuid handling across multiple adapters and update related tests
2026-07-13 00:42:46 +08:00
RockChinQ 3ddebd26ae fix: skip valkey-glide on Windows (#2333)
Skip the unsupported valkey-glide dependency on Windows while preserving automatic installation on supported platforms. Keep missing-client runtime and test paths safe, and update the Valkey integration documentation.
2026-07-13 00:22:12 +08:00
Guanchao Wang 2a3f7f8059 fix(mcp): harden remote SSE fallback (#2324)
* fix(mcp): harden remote SSE fallback

* fix(mcp): preserve transport errors during fallback

---------

Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com>
2026-07-11 22:08:37 +08:00
RockChinQ 940234a0d8 docs: update blog links to main site 2026-07-09 14:13:04 -04:00
Daria Korenieva 0c405901d2 feat(vector): add Valkey Search vector database backend (#2276)
* feat(vector): add Valkey Search vector database backend

Add a new opt-in VectorDatabase backend backed by the Valkey Search module
(valkey/valkey-bundle), accessed via the official valkey-glide client's native
ft command namespace.

- Implements the full VectorDatabase ABC: VECTOR, FULL_TEXT and HYBRID search,
  all 8 metadata filter operators, and pagination with exact totals.
- HYBRID uses filter-then-KNN (no app-side weighted fusion); vector_weight is
  accepted for interface parity but NOT honored (docstring + one-time warning +
  docs caveat).
- Lazy connect so a down Valkey never blocks boot; mandatory
  client_name=langbot_vector_client; optional auth + TLS (never logged).
- Registered via a single elif branch in vector/mgr.py; disabled by default
  (vdb.use stays chroma) for toC compatibility.
- Adds valkey-glide>=2.4.1,<3.0.0; no protobuf/pydantic downgrade; no ORM
  change so no Alembic migration.
- Unit tests (fast lane, no server) + slow-gated integration tests
  (TEST_VALKEY_URL, valkey/valkey-bundle:9.1.0) + integration doc.

* fix(vector): paginate Valkey Search deletes and guard delete_by_filter

Address self-review follow-ups for the Valkey Search VDB backend:

- _search_keys now paginates through the full result set in batches of
  _DELETE_SCAN_BATCH instead of capping at a single hard-coded 10000-key
  page, so delete_by_file_id / delete_by_filter fully remove files and
  filters that match more than one page of chunks (no orphaned vectors).
- Add unit regression tests for the delete_by_filter mass-deletion guard:
  a filter referencing only non-indexed fields must skip and return 0
  (never fall back to match-all), and a supported filter still deletes
  matching keys.

* refactor(vector): harden Valkey Search backend and add adversarial tests

Address the self-review NICE-TO-HAVE items for the Valkey Search VDB backend:
- Guard the username-without-password credential edge (skip auth + warn
  instead of building ServerCredentials(password=None, ...), which glide
  rejects).
- Add an async close() teardown that closes the glide client and resets
  cached state (re-init is safe via the existing None guard).
- Hoist 'import json' to module top (was imported inside three methods).
- Document the FT TAG literal-brace limitation in _escape_tag (fails closed,
  never widens).

Tests:
- Add an adversarial-input integration test proving crafted file_id /
  query_text cannot break out of or widen a query (fail-closed on braces).
- Add unit tests for close() and the credential-build guard.

Signed-off-by: Daria Korenieva <daric2612@gmail.com>

* fix(vector): make Valkey Search file_id TAG support arbitrary characters

Valkey Search's FT TAG query parser cannot handle '{', '}' or '*' even when
backslash-escaped, so a file_id containing those characters previously
produced an unparseable query (it failed closed / raised). Percent-encode
exactly those FT-unsafe characters (plus '%' for reversibility) in the
file_id TAG value, applied identically at write time and query time, so an
arbitrary file_id round-trips. For normal UUID/hash ids this is a no-op and
the stored value is unchanged; the original file_id is always preserved
verbatim in metadata_json.

Strengthen the adversarial integration test to assert a brace/star-bearing
file_id matches and deletes exactly its own row (no widening, no raise), and
add unit tests for _encode_file_id and the filter encoding.

Signed-off-by: Daria Korenieva <daric2612@gmail.com>

* refactor(vector): address Valkey Search review feedback

- Add configurable request_timeout (default 5000ms; glide default 250ms is
  too low for KNN); expose in config.yaml + docs table
- Validate embedding dimension consistency in add_embeddings (fail fast on
  mixed lengths to avoid silent KNN corruption)
- Use ft.info (O(1)) instead of ft.list (O(n)) for index existence checks in
  the query hot path; also closes the check-then-create TOCTOU window
- Pipeline HSETs via a non-atomic Batch instead of N sequential awaits
- Extract shared _iter_reply_docs to deduplicate reply parsing between
  _reply_to_chroma and list_by_filter
- Parenthesize multi-condition pre-filters before the => KNN clause
- Fail closed when a username is configured without a password
- Catch only RequestError on ft.dropindex (let connection/auth errors surface)
- Bound the delete_collection SCAN loop with a safety cap
- Add VectorDatabase.close() (no-op default) + VectorDBManager.shutdown()
- Simplify _MATCH_ALL literal; normalize typing to builtin generics

* fix(vector/valkey_search): address round-2 review feedback

- Serialize lazy client creation with an asyncio.Lock (double-checked) so
  concurrent first-use callers don't construct and leak duplicate clients.
- Make the filter operator chain exhaustive: raise on an unhandled op rather
  than silently dropping the condition (which could widen delete_by_filter).
- Cast numeric range (///) values to float, failing closed on
  non-numeric input and pre-empting a future NUMERIC-field injection surface.

* refactor(vector): remove shutdown/close from base ABC per maintainer feedback Per maintainer request, interface changes to VectorDatabase ABC and VectorDBManager should be in a separate PR with implementation across all backends. The ValkeySearchVectorDatabase.close() method remains but does not override an ABC method.

Signed-off-by: Daria Korenieva <daric2612@gmail.com>

* docs(test): list valkey_search in vdb coverage exclusions Add valkey_search to the documented vector/vdbs/ coverage-exclusion list, matching the existing chroma/milvus/pgvector/qdrant/seekdb entries. These adapters require a live database instance and are covered by env-gated integration tests instead of unit tests.

Signed-off-by: Daria Korenieva <daric2612@gmail.com>

---------

Signed-off-by: Daria Korenieva <daric2612@gmail.com>
2026-07-08 06:59:16 +08:00
Hyu 00e2103873 docs(config): add box.default_memory_mb to config.yaml template (#2319)
Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
2026-07-04 15:18:27 +08:00
Hyu 209706b0b9 feat(mcp): add box.default_memory_mb config for nsjail memory limit (#2318)
Operators can now set a global default memory limit for all stdio MCP
servers in config.yaml or via environment variable:

  config.yaml:
    box:
      default_memory_mb: 2048  # default: 1536

  env:
    BOX__DEFAULT_MEMORY_MB=2048

The default is raised from 1024 to 1536 MB — a safer floor for
Node.js V8 + WASM (undici llhttp) under nsjail cgroup limits.
Individual MCP servers can still override via their own box.memory_mb.

Previously the fallback was hardcoded to 1024 MB, causing OOM kills
(return_code=137) on node/npx MCP servers that need more RAM.

Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
2026-07-04 14:24:06 +08:00
Hyu 205404e3da fix(deps): pin langbot-plugin 0.4.13 (memory_mb removed from _COMPAT_FIELDS) (#2317)
Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
2026-07-04 13:45:13 +08:00
Hyu 3e93ccfb45 fix(mcp): use uniform session memory_mb=1024 to avoid BoxSessionConflictError (#2316)
All MCPs share one Box session (mcp-shared). When session memory_mb differed
by command type (512 for python, 1024 for node), the second MCP to call
create_session raised BoxSessionConflictError. Fix: always use 1024 MB for
the shared session so python and node MCPs coexist without conflict.

Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
2026-07-04 09:55:49 +08:00
Hyu c5d7e3dcb1 fix(deps): pin langbot-plugin 0.4.12 (BoxManagedProcessSpec.memory_mb fix) (#2315)
Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
2026-07-04 09:31:09 +08:00
Hyu cd6d5d9c2f fix(mcp): pin langbot-plugin 0.4.10 - per-process memory_mb for node OOM fix (#2314)
* fix(mcp): bump default memory to 1024MB for node (npx) stdio MCP servers

Node.js MCP servers (npx/bunx) were being OOM-killed (return_code=137) by the
default 512MB nsjail cgroup_mem_max. Node V8 reserves large virtual address
space and instantiates WebAssembly modules (undici llhttp) on startup, easily
exceeding 512MB resident. This caused every node-based MCP (memory,
sequential-thinking, filesystem, weather, docker, excel) to crash-loop.

Fix: when the stdio command is npx/bunx/pnpm, default memory_mb to 1024 unless
the operator explicitly set a value. Python/uvx servers keep the 512MB default.

* chore(deps): pin langbot-plugin 0.4.10 (per-process memory_mb fix)

* chore: update uv.lock for langbot-plugin 0.4.10

---------

Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
2026-07-04 09:03:43 +08:00
Hyu 1f7d9339fc fix(mcp): bump default memory to 1024MB for node (npx) stdio MCP servers (#2313)
Node.js MCP servers (npx/bunx) were being OOM-killed (return_code=137) by the
default 512MB nsjail cgroup_mem_max. Node V8 reserves large virtual address
space and instantiates WebAssembly modules (undici llhttp) on startup, easily
exceeding 512MB resident. This caused every node-based MCP (memory,
sequential-thinking, filesystem, weather, docker, excel) to crash-loop.

Fix: when the stdio command is npx/bunx/pnpm, default memory_mb to 1024 unless
the operator explicitly set a value. Python/uvx servers keep the 512MB default.

Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
2026-07-04 08:31:08 +08:00
RockChinQ f3b5fcfb7c fix mcp sidebar status sync (#2312)
Co-authored-by: Hyu <chenhyu@proton.me>
2026-07-03 20:23:12 +08:00
Hyu ffabb91bfe fix(mcp): add missing MCPLogs import to MCPForm (#2311)
Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
2026-07-03 17:59:02 +08:00
Hyu 96c84740db feat(mcp): add logs tab to MCP server detail panel (frontend) (#2310)
* feat(mcp): add logs tab to MCP server detail panel

- Add MCPLogs component mirroring PluginLogs functionality
- Add getMcpServerLogs API method to BackendClient
- Add logs tab to MCPForm detail panel (edit mode only)
- Add i18n keys (tabLogs, logsLevelAll, logsRefresh, logsAutoRefresh, logsEmpty) to all 8 locales
- Auto-refresh logs every 3s with toggle
- Filter logs by level (ALL/DEBUG/INFO/WARNING/ERROR)

* style: fix prettier formatting in MCPForm.tsx logs tab

* style: fix prettier in i18n locale files

---------

Co-authored-by: dadachann <dadachann@users.noreply.github.com>
Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
2026-07-03 16:52:15 +08:00
Hyu 85b5b5b54b feat(mcp): add MCP server log panel backend (#2308)
- Add log buffer (_log_buffer: deque with maxlen=500) to RuntimeMCPSession
- Capture stderr from Box managed process in monitor_process_health loop
- Add get_mcp_server_logs service method with limit and level filtering
- Add GET /servers/<server_name>/logs HTTP endpoint
- Parse log level from stderr lines (error/warning/debug/info)
- Tests passing: 211 passed, 12 skipped

Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
2026-07-03 16:05:23 +08:00
Hyu 28bffdef21 fix(mcp): set _preserve_managed_process before finally in cold-start path (#2309)
_ColdStartRetry was caught in _lifecycle_loop_with_retry which set
_preserve_managed_process = True — but by then the finally block inside
_lifecycle_loop had already run and called _cleanup_box_stdio_session(),
stopping the live managed process (return_code=143 SIGTERM). The cold-start
retry then restarted a fresh process, eliminating the warm-up advantage.

Fix: add an explicit except _ColdStartRetry in _lifecycle_loop that sets
_preserve_managed_process = True before re-raising. The finally block then
sees the flag and skips stop_managed_process, leaving the live process
untouched for the next handshake attempt.

Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
2026-07-03 15:26:18 +08:00
Hyu bf3c96026b fix(mcp): fix cold-start connection drop by using lexically-intact owner exit stack (#2307)
The previous _TransferredStack approach broke anyio lexical context:
websocket_client/ClientSession use anyio task groups whose cancel scope is
bound to the frame that entered them. Deferring their aclose via a transferred
exit stack left the underlying memory streams closed once initialize() returned,
so the very next request (refresh -> list_tools) failed with Connection closed.

New design:
- Attach on the owner exit stack (same task as the serve loop, lexically intact)
- A cold-starting process makes initialize() fail; signal _ColdStartRetry up to
  the outer retry loop, which reuses the live process without consuming retry budget
- _lifecycle_loop_with_retry handles _ColdStartRetry like _TransportReconnect:
  preserves process, no fatal budget, backs off 2s and retries
- Two new unit tests: cold-start raises _ColdStartRetry (not fatal) when process
  is alive; raises fatal error when process has actually exited

Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
2026-07-03 14:58:40 +08:00
Hyu ed9343c686 fix(mcp): retry the stdio handshake during slow (npx) cold starts (#2306)
A node/npx stdio MCP server (e.g. firecrawl-mcp via npx -y) failed on first
connect with Connection closed / Failed after 4 attempts, even though the
process was fine. An npx cold start downloads+installs the package before the
server can answer the MCP handshake (measured ~27s for a simple official
server; longer for heavier ones). The old code attached the WS and called
session.initialize() the instant the process was started, so the handshake ran
before the process could answer and failed; the outer lifecycle retry then
rebuilt the process, churning it in a loop.

Verified decisively: attaching + initialize() against a mid-cold-start process
times out on attempt 1 (process still installing) but SUCCEEDS at t+0.6s on
attempt 2 once the process is ready. So the fix is to retry the handshake in
place, not to rebuild the process.

Changes (mcp_stdio.initialize):
- Start the managed process ONCE, then loop attach WS -> ClientSession ->
  initialize() within the startup_timeout budget, tearing down each failed
  attempt cleanly, until the handshake succeeds or the budget elapses. A
  successful transport/session is transferred into the owner exit stack via a
  small _TransferredStack adapter.
- Bound each attempt with asyncio.wait_for(initialize, _HANDSHAKE_ATTEMPT_TIMEOUT_SEC=10s)
  so a cold-starting process fails fast and retries instead of hanging until
  the transport drops.
- Stop retrying ONLY when the process has DEFINITIVELY exited: new
  _managed_process_has_exited() (checks EXITED status) replaces the previous
  not-_managed_process_is_running() test, which false-negatived on a
  just-spawned process that had not yet reported RUNNING and made the loop bail
  to the outer rebuild path (relay then rejected the early re-attach with HTTP 400).

Adds a unit test that fails the first two handshakes with the process alive and
asserts the loop retries to success while starting the process exactly once.

Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
2026-07-03 14:16:57 +08:00
Hyu 9a8cdde86c refactor(mcp): make MCP test reuse the shared Box session (near-instant tests) (#2304)
* refactor(mcp): make MCP test reuse the shared Box session instead of a per-test session

Testing an MCP server (config-page "test" button) previously spun up a fresh
isolated mcp-test-<uuid> Box session every time: cold-start the container, run
the dependency bootstrap, probe, then tear the whole session down. That is slow
(tens of seconds) and, on an already-hosted server, wasteful — the server is
already running in the shared session.

Change the test to reuse the shared session / live process:

- _build_box_session_id: transient tests now use mcp-shared, the same Box
  session as live servers, so a test reuses the running container (and, for an
  existing server, its live managed process) instead of a cold per-test session.
- cleanup_session: a transient test no longer deletes the whole session (which
  under the shared model would kill every other MCP server in the container). It
  stops only its own process_id, exactly like a live server. Isolation is now at
  the process level (distinct process_id per server/test), not the session level.
- test_mcp_server (persisted server): reuse the live connection with a real
  list_tools refresh/probe; only fall back to a full start() when there is no
  live connection to probe or the refresh fails, instead of an ERROR->start()
  rebuild.

Trade-off: a failing test now shares the container with live servers rather than
a throwaway session. Accepted deliberately in favour of near-instant tests;
process-level isolation keeps a test from stopping another server's process.

* chore(deps): pin langbot-plugin 0.4.9 for the nsjail RLIMIT_AS node/npx MCP fix

---------

Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
2026-07-03 13:14:14 +08:00
Hyu a2655e16f8 fix(mcp): survive transient WS transport drops for Box stdio MCP servers (#2303)
* fix(mcp): survive transient WS transport drops for Box stdio MCP servers

A Box-backed stdio MCP server (e.g. pab1it0/prometheus) would periodically
error on the frontend with Box managed process exited unexpectedly /
Failed after 4 attempts once the session had been alive for a while.

Root cause: the managed MCP process lives in the Box runtime and SURVIVES a
WebSocket transport drop, but _lifecycle_loop treated any monitor completion
as a fatal process death. It then ran the finally-block cleanup — which STOPS
the still-healthy managed process — and did a full 4-attempt exponential
backoff rebuild. Under an occasionally-stalled single-worker event loop the
mcp websocket client misses a ping/pong, the transport drops, and this
self-inflicted teardown loop is what the user sees.

Fixes:
- _lifecycle_loop: when the health monitor completes, re-check the real
  managed-process state. If the process is still running, the transport
  merely dropped: raise an internal _TransportReconnect signal instead of
  Box managed process exited unexpectedly.
- _lifecycle_loop_with_retry: handle _TransportReconnect as a free, uncounted
  reconnect (does not consume the fatal retry budget), so a long-lived session
  survives arbitrarily many transient drops.
- finally-block: gate managed-process teardown on a _preserve_managed_process
  flag so a transport-only reconnect closes just the WS, not the process.
- BoxStdioSessionRuntime.initialize: reuse an already-running managed process
  instead of stopping+rebuilding it (which also re-ran the slow dependency
  bootstrap); only (re)start when none is running. Adds
  _managed_process_is_running() helper.

Pairs with langbot-plugin-sdk fix adding a server-driven WS heartbeat to the
managed-process relay, which prevents most drops in the first place.

* style(mcp): ruff format

* chore(deps): pin langbot-plugin 0.4.8 for the managed-process WS heartbeat fix

---------

Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
2026-07-03 12:24:24 +08:00
Hyu f89611f39a chore: release v4.10.5 2026-07-03 00:59:53 +08:00
RockChinQ 04a199a3b2 fix: preserve MCP server ids with slashes
Fixes #2301
2026-07-03 00:17:29 +08:00
Hyu f85278f98b Fix monitoring CI regressions 2026-07-02 17:22:33 +08:00
Hyu 0ce382fc8b Improve pipeline monitoring and AI tab resilience 2026-07-02 16:53:50 +08:00
Hyu e32e515bc9 Tone down tool call bubbles 2026-07-02 16:53:50 +08:00
Hyu c809e3d14f Add tool call observability 2026-07-02 16:53:50 +08:00
Hyu b1bc05d5d3 Improve monitoring conversation turns 2026-07-02 16:53:50 +08:00
Stevenqin 13dba887d5 fix(wecomcs): implement proactive text sends (#2300) 2026-07-02 14:39:05 +08:00
彼方 cc7a13158e fix(aiocqhttp): resolve group member metadata (#2298)
* fix(aiocqhttp): resolve group member metadata

* style: ruff format
2026-07-01 20:00:47 +08:00
RockChinQ 7de7c7f714 docs: update Product Hunt badge to featured 2026-06-30 08:23:55 -04:00
Junyan Qin 85923e3d7b fix(migrations): shorten mcp resource revision id 2026-06-30 19:39:23 +08:00
Junyan Qin c7c6c5dc51 fix(i18n): add skill tools tooltip translations 2026-06-30 19:37:57 +08:00
Junyan Qin b056646696 fix(migrations): add mcp resource preferences 2026-06-30 19:36:04 +08:00
advancer-young 096ec1a8ce feat(mcp): support mcp resources (#2215)
* feat(mcp): support mcp resources

* feat(web): split MCP resources into tab

* docs: add MCP resources PR review

* feat(mcp): productionize resource support

* feat(mcp): scope local agent tools and resources

* fix(web): gate space embedding models behind login

* fix(web): prevent clipped space model CTA

* test: update preproc resource tool expectations

* fix(web): expose skill authoring tools in selector

---------

Co-authored-by: yang.xiang <yang.xiang@advancegroup.com>
Co-authored-by: Hyu <chenhyu@proton.me>
Co-authored-by: Junyan Qin <rockchinq@gmail.com>
2026-06-30 19:16:30 +08:00
RockChinQ 2618e06492 docs: update 302.AI referral link to share.302ai.cn 2026-06-30 05:10:17 -04:00
彼方 5c8c0eb17b test: use package import paths (#2297) 2026-06-30 10:51:34 +08:00
彼方 ccc51522cf fix(aiocqhttp): normalize base64 media payloads (#2296)
* fix(aiocqhttp): normalize image and voice base64 payloads

* fix(aiocqhttp): support file messages from base64 payloads

* test(aiocqhttp): use package import path
2026-06-30 10:51:09 +08:00
彼方 6a99a83f2d chore(scripts): mark scripts executable (#2295) 2026-06-30 10:50:26 +08:00
Hyu ebab5343cf fix(i18n): add bots.admins keys to all locale files (#2292)
Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
2026-06-27 17:38:30 +08:00
Hyu b97cc800d3 feat(platform): migrate bot admins from config.yaml to database (#2289)
* feat(platform): migrate bot admins from config.yaml to database

- Add BotAdmin ORM model (bot_admins table) scoped per bot_uuid
- Add Alembic migration 0007 to create table and migrate legacy config admins
- Remove top-level admins key from config.yaml template
- Add GET/POST/DELETE /api/v1/platform/bots/<uuid>/admins endpoints
- Update cmdmgr privilege check to query bot_admins table (bot-scoped)
- Add BotAdminsPanel frontend component in bot detail sessions tab
- Add i18n keys (zh-Hans, en-US)

* fix(ci): ruff/prettier format, fix test_importutil assertion

* fix(ci): prettier format BackendClient.ts and i18n locales

* fix(ci): eslint-prettier fix BackendClient.ts single-param formatting

* refactor: move admin management into session monitor, fix session_id enum format

- Remove standalone admins tab, replace with BotAdminsDialog in session monitor
- Add admin toggle button inline in chat header next to Active status
- Add BotAdminsDialog component with useBotAdmins hook
- Fix session_id written as LauncherTypes.PERSON_xxx instead of person_xxx
  (monitoring_helper.py x4, pipelinemgr.py x1 missing .value on launcher_type)
- Fix duplicate platform/person label in session list and chat header
- Migrate existing malformed session_id records in DB

---------

Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
2026-06-27 17:18:19 +08:00
彼方 48905ea080 feat(plugin): report deferred response delivery failures (#2287)
* feat(plugin): report deferred response delivery failures

* style: fix ruff format issues in plugin_diagnostics and test_handler_actions

---------

Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com>
2026-06-26 23:45:10 +08:00
Hyu ddb77fc43c fix(api): guard /set-password with allow_modify_login_info (#2288)
The /change-password and /bind-space endpoints already refuse when
system.allow_modify_login_info is false, but /set-password did not,
leaving a path to alter login credentials on locked-down deployments
(e.g. public demo instances). Apply the same guard.

Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
2026-06-26 16:35:50 +08:00
196 changed files with 26614 additions and 1688 deletions
+14 -20
View File
@@ -5,7 +5,7 @@
<div align="center">
<a href="https://www.producthunt.com/products/langbot?utm_source=badge-follow&utm_medium=badge&utm_source=badge-langbot" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/follow.svg?product_id=1077185&theme=light" alt="LangBot - Production&#0045;grade&#0032;IM&#0032;bot&#0032;made&#0032;easy&#0046; | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
<a href="https://www.producthunt.com/products/langbot/launches/langbot?embed=true&amp;utm_source=badge-featured&amp;utm_medium=badge&amp;utm_campaign=badge-langbot" target="_blank" rel="noopener noreferrer"><img alt="LangBot - Easy-to-use global IM bot platform designed for the LLM era | Product Hunt" width="250" height="54" src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=979554&amp;theme=light&amp;t=1782822143403"></a>
<h3>Production-grade platform for building agentic IM bots.</h3>
<h4>Quickly build, debug, and ship AI bots to Slack, Discord, Telegram, WeChat, and more.</h4>
@@ -51,7 +51,7 @@ LangBot is an **open-source, production-grade platform** for building AI-powered
[→ Learn more about all features](https://link.langbot.app/en/docs/features)
📍 Practical guides: [deploy a multi-platform AI bot in 5 minutes](https://blog.langbot.app/en/blog/deploy-ai-bot-in-5-minutes/), [connect DeepSeek to WeChat, Discord, and Telegram](https://blog.langbot.app/en/blog/connect-deepseek-to-wechat/), [run a Dify Agent in Discord, Telegram, and Slack](https://blog.langbot.app/en/blog/dify-agent-discord-telegram-slack/), and [build an n8n-powered chatbot](https://blog.langbot.app/en/blog/n8n-multi-platform-ai-chatbot/).
📍 Practical guides: [deploy a multi-platform AI bot in 5 minutes](https://langbot.app/en/blog/deploy-ai-bot-in-5-minutes/), [connect DeepSeek to WeChat, Discord, and Telegram](https://langbot.app/en/blog/connect-deepseek-to-wechat/), [run a Dify Agent in Discord, Telegram, and Slack](https://langbot.app/en/blog/dify-agent-discord-telegram-slack/), and [build an n8n-powered chatbot](https://langbot.app/en/blog/n8n-multi-platform-ai-chatbot/).
---
@@ -92,6 +92,17 @@ docker compose --profile all up -d
---
## Live Demo
**Try it now:** https://demo.langbot.dev/
- Email: `demo@langbot.app`
- Password: `langbot123456`
_Note: Public demo environment. Do not enter sensitive information._
---
## Supported Platforms
| Platform | Status | Notes |
@@ -136,7 +147,7 @@ docker compose --profile all up -d
| [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | GPU Platform | ✅ |
| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | GPU Platform | ✅ |
| [接口 AI](https://jiekou.ai/) | Gateway | ✅ |
| [302.AI](https://share.302.ai/SuTG99) | Gateway | ✅ |
| [302.AI](https://share.302ai.cn/SuTG99) | Gateway | ✅ |
| [Qiniu](https://www.qiniu.com/ai/agent) | Gateway | ✅ |
[→ View all integrations](https://link.langbot.app/en/docs/features)
@@ -167,17 +178,6 @@ LangBot is **agent-friendly by design** — your coding agents (Claude Code, Cod
---
## Live Demo
**Try it now:** https://demo.langbot.dev/
- Email: `demo@langbot.app`
- Password: `langbot123456`
_Note: Public demo environment. Do not enter sensitive information._
---
## Community
[![Discord](https://img.shields.io/discord/1335141740050649118?logo=discord&label=Discord)](https://discord.gg/wdNEHETs87)
@@ -186,12 +186,6 @@ _Note: Public demo environment. Do not enter sensitive information._
---
## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=langbot-app/LangBot&type=Date)](https://star-history.com/#langbot-app/LangBot&Date)
---
## Contributors
Thanks to all [contributors](https://github.com/langbot-app/LangBot/graphs/contributors) who have helped make LangBot better:
+12 -18
View File
@@ -51,7 +51,7 @@ LangBot 是一个**开源的生产级平台**,用于构建 AI 驱动的即时
[→ 了解更多功能特性](https://link.langbot.app/zh/docs/features)
📍 实践指南:[5 分钟部署多平台 AI 机器人](https://blog.langbot.app/zh/blog/deploy-ai-bot-in-5-minutes/)、[将 DeepSeek 接入微信、企业微信与 Discord](https://blog.langbot.app/zh/blog/connect-deepseek-to-wechat/)、[让 Dify Agent 跑在 Discord、Telegram 和 Slack 上](https://blog.langbot.app/zh/blog/dify-agent-discord-telegram-slack/),以及[用 n8n 构建多平台 AI 聊天机器人](https://blog.langbot.app/zh/blog/n8n-multi-platform-ai-chatbot/)。
📍 实践指南:[5 分钟部署多平台 AI 机器人](https://langbot.app/zh/blog/deploy-ai-bot-in-5-minutes/)、[将 DeepSeek 接入微信、企业微信与 Discord](https://langbot.app/zh/blog/connect-deepseek-to-wechat/)、[让 Dify Agent 跑在 Discord、Telegram 和 Slack 上](https://langbot.app/zh/blog/dify-agent-discord-telegram-slack/),以及[用 n8n 构建多平台 AI 聊天机器人](https://langbot.app/zh/blog/n8n-multi-platform-ai-chatbot/)。
---
@@ -92,6 +92,16 @@ docker compose --profile all up -d
---
## 在线演示
**立即体验:** https://demo.langbot.dev/
- 邮箱:`demo@langbot.app`
- 密码:`langbot123456`
*注意:公开演示环境,请不要在其中填入任何敏感信息。*
---
## 支持的平台
| 平台 | 状态 | 备注 |
@@ -136,7 +146,7 @@ docker compose --profile all up -d
| [优云智算](https://www.compshare.cn/?ytag=GPU_YY-gh_langbot) | GPU 平台 | ✅ |
| [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | GPU 平台 | ✅ |
| [接口 AI](https://jiekou.ai/) | 聚合平台 | ✅ |
| [302.AI](https://share.302.ai/SuTG99) | 聚合平台 | ✅ |
| [302.AI](https://share.302ai.cn/SuTG99) | 聚合平台 | ✅ |
| [小马算力](https://www.tokenpony.cn/453z1) | 聚合平台 | ✅ |
| [百宝箱Tbox](https://www.tbox.cn/open) | 智能体平台 | ✅ |
| [七牛云Qiniu](https://www.qiniu.com/ai/agent) | 聚合平台 | ✅ |
@@ -170,16 +180,6 @@ docker compose --profile all up -d
---
## 在线演示
**立即体验:** https://demo.langbot.dev/
- 邮箱:`demo@langbot.app`
- 密码:`langbot123456`
*注意:公开演示环境,请不要在其中填入任何敏感信息。*
---
## 为 AI Agent 而生 🤖
LangBot **从设计上就对 Agent 友好** —— 你的编码 AgentClaude Code、Codex、Copilot、Cursor 等)可以一等公民般地操作、扩展和部署 LangBot:
@@ -203,12 +203,6 @@ LangBot **从设计上就对 Agent 友好** —— 你的编码 AgentClaude C
---
## Star 趋势
[![Star History Chart](https://api.star-history.com/svg?repos=langbot-app/LangBot&type=Date)](https://star-history.com/#langbot-app/LangBot&Date)
---
## 贡献者
感谢所有[贡献者](https://github.com/langbot-app/LangBot/graphs/contributors)对 LangBot 的帮助:
+13 -17
View File
@@ -5,7 +5,7 @@
<div align="center">
<a href="https://www.producthunt.com/products/langbot?utm_source=badge-follow&utm_medium=badge&utm_source=badge-langbot" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/follow.svg?product_id=1077185&theme=light" alt="LangBot - Production&#0045;grade&#0032;IM&#0032;bot&#0032;made&#0032;easy&#0046; | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
<a href="https://www.producthunt.com/products/langbot/launches/langbot?embed=true&amp;utm_source=badge-featured&amp;utm_medium=badge&amp;utm_campaign=badge-langbot" target="_blank" rel="noopener noreferrer"><img alt="LangBot - Easy-to-use global IM bot platform designed for the LLM era | Product Hunt" width="250" height="54" src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=979554&amp;theme=light&amp;t=1782822143403"></a>
<h3>Plataforma de grado de producción para construir bots de mensajería instantánea con agentes de IA.</h3>
<h4>Construya, depure y despliegue bots de IA rápidamente en Slack, Discord, Telegram, WeChat y más.</h4>
@@ -50,7 +50,7 @@ LangBot es una **plataforma de código abierto y grado de producción** para con
[→ Conocer más sobre todas las funcionalidades](https://link.langbot.app/en/docs/features)
📍 Guías prácticas: [desplegar un bot de IA multiplataforma en 5 minutos](https://blog.langbot.app/en/blog/deploy-ai-bot-in-5-minutes/), [conectar DeepSeek a WeChat, Discord y Telegram](https://blog.langbot.app/en/blog/connect-deepseek-to-wechat/), [ejecutar un Dify Agent en Discord, Telegram y Slack](https://blog.langbot.app/en/blog/dify-agent-discord-telegram-slack/) y [crear un chatbot con n8n](https://blog.langbot.app/en/blog/n8n-multi-platform-ai-chatbot/).
📍 Guías prácticas: [desplegar un bot de IA multiplataforma en 5 minutos](https://langbot.app/en/blog/deploy-ai-bot-in-5-minutes/), [conectar DeepSeek a WeChat, Discord y Telegram](https://langbot.app/en/blog/connect-deepseek-to-wechat/), [ejecutar un Dify Agent en Discord, Telegram y Slack](https://langbot.app/en/blog/dify-agent-discord-telegram-slack/) y [crear un chatbot con n8n](https://langbot.app/en/blog/n8n-multi-platform-ai-chatbot/).
---
@@ -91,6 +91,16 @@ docker compose --profile all up -d
---
## Demo en Vivo
**Pruébelo ahora:** https://demo.langbot.dev/
- Correo electrónico: `demo@langbot.app`
- Contraseña: `langbot123456`
*Nota: Entorno de demostración público. No ingrese información confidencial.*
---
## Plataformas Soportadas
| Plataforma | Estado | Notas |
@@ -135,7 +145,7 @@ docker compose --profile all up -d
| [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | Plataforma GPU | ✅ |
| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | Plataforma GPU | ✅ |
| [接口 AI](https://jiekou.ai/) | Pasarela | ✅ |
| [302.AI](https://share.302.ai/SuTG99) | Pasarela | ✅ |
| [302.AI](https://share.302ai.cn/SuTG99) | Pasarela | ✅ |
| [Qiniu](https://www.qiniu.com/ai/agent) | Pasarela | ✅ |
[→ Ver todas las integraciones](https://link.langbot.app/en/docs/features)
@@ -153,14 +163,6 @@ docker compose --profile all up -d
---
## Demo en Vivo
**Pruébelo ahora:** https://demo.langbot.dev/
- Correo electrónico: `demo@langbot.app`
- Contraseña: `langbot123456`
*Nota: Entorno de demostración público. No ingrese información confidencial.*
## Diseñado para Agentes de IA 🤖
LangBot es **agent-friendly por diseño** —— tus agentes de codificación (Claude Code, Codex, Copilot, Cursor, …) pueden operar, extender y desplegar LangBot con soporte de primera clase:
@@ -182,12 +184,6 @@ LangBot es **agent-friendly por diseño** —— tus agentes de codificación (C
---
## Historial de Stars
[![Star History Chart](https://api.star-history.com/svg?repos=langbot-app/LangBot&type=Date)](https://star-history.com/#langbot-app/LangBot&Date)
---
## Colaboradores
Gracias a todos los [colaboradores](https://github.com/langbot-app/LangBot/graphs/contributors) que han ayudado a mejorar LangBot:
+13 -17
View File
@@ -5,7 +5,7 @@
<div align="center">
<a href="https://www.producthunt.com/products/langbot?utm_source=badge-follow&utm_medium=badge&utm_source=badge-langbot" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/follow.svg?product_id=1077185&theme=light" alt="LangBot - Production&#0045;grade&#0032;IM&#0032;bot&#0032;made&#0032;easy&#0046; | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
<a href="https://www.producthunt.com/products/langbot/launches/langbot?embed=true&amp;utm_source=badge-featured&amp;utm_medium=badge&amp;utm_campaign=badge-langbot" target="_blank" rel="noopener noreferrer"><img alt="LangBot - Easy-to-use global IM bot platform designed for the LLM era | Product Hunt" width="250" height="54" src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=979554&amp;theme=light&amp;t=1782822143403"></a>
<h3>Plateforme de niveau production pour construire des bots de messagerie instantanée avec agents IA.</h3>
<h4>Créez, déboguez et déployez rapidement des bots IA sur Slack, Discord, Telegram, WeChat et plus.</h4>
@@ -50,7 +50,7 @@ LangBot est une **plateforme open-source de niveau production** pour créer des
[→ En savoir plus sur toutes les fonctionnalités](https://link.langbot.app/en/docs/features)
📍 Guides pratiques : [déployer un bot IA multiplateforme en 5 minutes](https://blog.langbot.app/en/blog/deploy-ai-bot-in-5-minutes/), [connecter DeepSeek à WeChat, Discord et Telegram](https://blog.langbot.app/en/blog/connect-deepseek-to-wechat/), [exécuter un Dify Agent dans Discord, Telegram et Slack](https://blog.langbot.app/en/blog/dify-agent-discord-telegram-slack/) et [créer un chatbot avec n8n](https://blog.langbot.app/en/blog/n8n-multi-platform-ai-chatbot/).
📍 Guides pratiques : [déployer un bot IA multiplateforme en 5 minutes](https://langbot.app/en/blog/deploy-ai-bot-in-5-minutes/), [connecter DeepSeek à WeChat, Discord et Telegram](https://langbot.app/en/blog/connect-deepseek-to-wechat/), [exécuter un Dify Agent dans Discord, Telegram et Slack](https://langbot.app/en/blog/dify-agent-discord-telegram-slack/) et [créer un chatbot avec n8n](https://langbot.app/en/blog/n8n-multi-platform-ai-chatbot/).
---
@@ -91,6 +91,16 @@ docker compose --profile all up -d
---
## Démo en Ligne
**Essayez maintenant :** https://demo.langbot.dev/
- Email : `demo@langbot.app`
- Mot de passe : `langbot123456`
*Note : Environnement de démonstration public. Ne saisissez pas d'informations sensibles.*
---
## Plateformes Supportées
| Plateforme | Statut | Notes |
@@ -132,7 +142,7 @@ docker compose --profile all up -d
| [ModelScope](https://modelscope.cn/docs/model-service/API-Inference/intro) | Passerelle | ✅ |
| [GiteeAI](https://ai.gitee.com/) | Passerelle | ✅ |
| [接口 AI](https://jiekou.ai/) | Passerelle | ✅ |
| [302.AI](https://share.302.ai/SuTG99) | Passerelle | ✅ |
| [302.AI](https://share.302ai.cn/SuTG99) | Passerelle | ✅ |
| [CompShare](https://www.compshare.cn/?ytag=GPU_YY-gh_langbot) | Plateforme GPU | ✅ |
| [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | Plateforme GPU | ✅ |
| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | Plateforme GPU | ✅ |
@@ -153,14 +163,6 @@ docker compose --profile all up -d
---
## Démo en Ligne
**Essayez maintenant :** https://demo.langbot.dev/
- Email : `demo@langbot.app`
- Mot de passe : `langbot123456`
*Note : Environnement de démonstration public. Ne saisissez pas d'informations sensibles.*
## Conçu pour les agents IA 🤖
LangBot est **agent-friendly par conception** —— vos agents de codage (Claude Code, Codex, Copilot, Cursor, …) peuvent exploiter, étendre et déployer LangBot avec un support de premier ordre :
@@ -182,12 +184,6 @@ LangBot est **agent-friendly par conception** —— vos agents de codage (Claud
---
## Historique des Stars
[![Star History Chart](https://api.star-history.com/svg?repos=langbot-app/LangBot&type=Date)](https://star-history.com/#langbot-app/LangBot&Date)
---
## Contributeurs
Merci à tous les [contributeurs](https://github.com/langbot-app/LangBot/graphs/contributors) qui ont aidé à améliorer LangBot :
+13 -17
View File
@@ -5,7 +5,7 @@
<div align="center">
<a href="https://www.producthunt.com/products/langbot?utm_source=badge-follow&utm_medium=badge&utm_source=badge-langbot" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/follow.svg?product_id=1077185&theme=light" alt="LangBot - Production&#0045;grade&#0032;IM&#0032;bot&#0032;made&#0032;easy&#0046; | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
<a href="https://www.producthunt.com/products/langbot/launches/langbot?embed=true&amp;utm_source=badge-featured&amp;utm_medium=badge&amp;utm_campaign=badge-langbot" target="_blank" rel="noopener noreferrer"><img alt="LangBot - Easy-to-use global IM bot platform designed for the LLM era | Product Hunt" width="250" height="54" src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=979554&amp;theme=light&amp;t=1782822143403"></a>
<h3>AIエージェント搭載IMボットを構築するための本番グレードプラットフォーム。</h3>
<h4>Slack、Discord、Telegram、WeChat などに AI ボットを素早く構築、デバッグ、デプロイ。</h4>
@@ -50,7 +50,7 @@ LangBot は、AI搭載のインスタントメッセージングボットを構
[→ すべての機能について詳しく見る](https://link.langbot.app/ja/docs/features)
📍 実践ガイド: [5分でマルチプラットフォームAIボットをデプロイ](https://blog.langbot.app/en/blog/deploy-ai-bot-in-5-minutes/)、[DeepSeekをWeChat・Discord・Telegramに接続](https://blog.langbot.app/en/blog/connect-deepseek-to-wechat/)、[Dify AgentをDiscord・Telegram・Slackで動かす](https://blog.langbot.app/en/blog/dify-agent-discord-telegram-slack/)、[n8n連携チャットボットを構築](https://blog.langbot.app/en/blog/n8n-multi-platform-ai-chatbot/)。
📍 実践ガイド: [5分でマルチプラットフォームAIボットをデプロイ](https://langbot.app/en/blog/deploy-ai-bot-in-5-minutes/)、[DeepSeekをWeChat・Discord・Telegramに接続](https://langbot.app/en/blog/connect-deepseek-to-wechat/)、[Dify AgentをDiscord・Telegram・Slackで動かす](https://langbot.app/en/blog/dify-agent-discord-telegram-slack/)、[n8n連携チャットボットを構築](https://langbot.app/en/blog/n8n-multi-platform-ai-chatbot/)。
---
@@ -91,6 +91,16 @@ docker compose --profile all up -d
---
## ライブデモ
**今すぐ試す:** https://demo.langbot.dev/
- メール: `demo@langbot.app`
- パスワード: `langbot123456`
*注意: 公開デモ環境です。機密情報を入力しないでください。*
---
## 対応プラットフォーム
| プラットフォーム | ステータス | 備考 |
@@ -135,7 +145,7 @@ docker compose --profile all up -d
| [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | GPUプラットフォーム | ✅ |
| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | GPUプラットフォーム | ✅ |
| [接口 AI](https://jiekou.ai/) | ゲートウェイ | ✅ |
| [302.AI](https://share.302.ai/SuTG99) | ゲートウェイ | ✅ |
| [302.AI](https://share.302ai.cn/SuTG99) | ゲートウェイ | ✅ |
| [Qiniu](https://www.qiniu.com/ai/agent) | ゲートウェイ | ✅ |
[→ すべての統合を表示](https://link.langbot.app/en/docs/features)
@@ -153,14 +163,6 @@ docker compose --profile all up -d
---
## ライブデモ
**今すぐ試す:** https://demo.langbot.dev/
- メール: `demo@langbot.app`
- パスワード: `langbot123456`
*注意: 公開デモ環境です。機密情報を入力しないでください。*
## AI エージェントのために 🤖
LangBot は **設計段階からエージェントフレンドリー** です。お使いのコーディングエージェント(Claude Code、Codex、Copilot、Cursor など)が、ファーストクラスのサポートで LangBot を操作・拡張・デプロイできます:
@@ -182,12 +184,6 @@ LangBot は **設計段階からエージェントフレンドリー** です。
---
## Star 推移
[![Star History Chart](https://api.star-history.com/svg?repos=langbot-app/LangBot&type=Date)](https://star-history.com/#langbot-app/LangBot&Date)
---
## コントリビューター
LangBot をより良くするために貢献してくださったすべての[コントリビューター](https://github.com/langbot-app/LangBot/graphs/contributors)に感謝します:
+13 -17
View File
@@ -5,7 +5,7 @@
<div align="center">
<a href="https://www.producthunt.com/products/langbot?utm_source=badge-follow&utm_medium=badge&utm_source=badge-langbot" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/follow.svg?product_id=1077185&theme=light" alt="LangBot - Production&#0045;grade&#0032;IM&#0032;bot&#0032;made&#0032;easy&#0046; | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
<a href="https://www.producthunt.com/products/langbot/launches/langbot?embed=true&amp;utm_source=badge-featured&amp;utm_medium=badge&amp;utm_campaign=badge-langbot" target="_blank" rel="noopener noreferrer"><img alt="LangBot - Easy-to-use global IM bot platform designed for the LLM era | Product Hunt" width="250" height="54" src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=979554&amp;theme=light&amp;t=1782822143403"></a>
<h3>AI 에이전트 IM 봇 구축을 위한 프로덕션 등급 플랫폼.</h3>
<h4>Slack, Discord, Telegram, WeChat 등에 AI 봇을 빠르게 구축, 디버그 및 배포.</h4>
@@ -50,7 +50,7 @@ LangBot은 AI 기반 인스턴트 메시징 봇을 구축하기 위한 **오픈
[→ 모든 기능 자세히 보기](https://link.langbot.app/en/docs/features)
📍 실전 가이드: [5분 만에 멀티 플랫폼 AI 봇 배포하기](https://blog.langbot.app/en/blog/deploy-ai-bot-in-5-minutes/), [DeepSeek를 WeChat, Discord, Telegram에 연결하기](https://blog.langbot.app/en/blog/connect-deepseek-to-wechat/), [Dify Agent를 Discord, Telegram, Slack에서 실행하기](https://blog.langbot.app/en/blog/dify-agent-discord-telegram-slack/), [n8n 기반 챗봇 만들기](https://blog.langbot.app/en/blog/n8n-multi-platform-ai-chatbot/).
📍 실전 가이드: [5분 만에 멀티 플랫폼 AI 봇 배포하기](https://langbot.app/en/blog/deploy-ai-bot-in-5-minutes/), [DeepSeek를 WeChat, Discord, Telegram에 연결하기](https://langbot.app/en/blog/connect-deepseek-to-wechat/), [Dify Agent를 Discord, Telegram, Slack에서 실행하기](https://langbot.app/en/blog/dify-agent-discord-telegram-slack/), [n8n 기반 챗봇 만들기](https://langbot.app/en/blog/n8n-multi-platform-ai-chatbot/).
---
@@ -91,6 +91,16 @@ docker compose --profile all up -d
---
## 라이브 데모
**지금 체험:** https://demo.langbot.dev/
- 이메일: `demo@langbot.app`
- 비밀번호: `langbot123456`
*참고: 공개 데모 환경입니다. 민감한 정보를 입력하지 마세요.*
---
## 지원 플랫폼
| 플랫폼 | 상태 | 비고 |
@@ -135,7 +145,7 @@ docker compose --profile all up -d
| [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | GPU 플랫폼 | ✅ |
| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | GPU 플랫폼 | ✅ |
| [接口 AI](https://jiekou.ai/) | 게이트웨이 | ✅ |
| [302.AI](https://share.302.ai/SuTG99) | 게이트웨이 | ✅ |
| [302.AI](https://share.302ai.cn/SuTG99) | 게이트웨이 | ✅ |
| [Qiniu](https://www.qiniu.com/ai/agent) | 게이트웨이 | ✅ |
[→ 모든 통합 보기](https://link.langbot.app/en/docs/features)
@@ -153,14 +163,6 @@ docker compose --profile all up -d
---
## 라이브 데모
**지금 체험:** https://demo.langbot.dev/
- 이메일: `demo@langbot.app`
- 비밀번호: `langbot123456`
*참고: 공개 데모 환경입니다. 민감한 정보를 입력하지 마세요.*
## AI 에이전트를 위한 설계 🤖
LangBot은 **설계 단계부터 에이전트 친화적**입니다 —— 코딩 에이전트(Claude Code, Codex, Copilot, Cursor 등)가 일급 지원으로 LangBot을 운영·확장·배포할 수 있습니다:
@@ -182,12 +184,6 @@ LangBot은 **설계 단계부터 에이전트 친화적**입니다 —— 코딩
---
## Star 추이
[![Star History Chart](https://api.star-history.com/svg?repos=langbot-app/LangBot&type=Date)](https://star-history.com/#langbot-app/LangBot&Date)
---
## 기여자
LangBot을 더 나은 프로젝트로 만들어 주신 모든 [기여자](https://github.com/langbot-app/LangBot/graphs/contributors)분들께 감사드립니다:
+13 -17
View File
@@ -5,7 +5,7 @@
<div align="center">
<a href="https://www.producthunt.com/products/langbot?utm_source=badge-follow&utm_medium=badge&utm_source=badge-langbot" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/follow.svg?product_id=1077185&theme=light" alt="LangBot - Production&#0045;grade&#0032;IM&#0032;bot&#0032;made&#0032;easy&#0046; | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
<a href="https://www.producthunt.com/products/langbot/launches/langbot?embed=true&amp;utm_source=badge-featured&amp;utm_medium=badge&amp;utm_campaign=badge-langbot" target="_blank" rel="noopener noreferrer"><img alt="LangBot - Easy-to-use global IM bot platform designed for the LLM era | Product Hunt" width="250" height="54" src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=979554&amp;theme=light&amp;t=1782822143403"></a>
<h3>Платформа производственного уровня для создания агентных IM-ботов.</h3>
<h4>Быстро создавайте, отлаживайте и развертывайте ИИ-ботов в Slack, Discord, Telegram, WeChat и других платформах.</h4>
@@ -50,7 +50,7 @@ LangBot — это **платформа с открытым исходным к
[→ Подробнее обо всех возможностях](https://link.langbot.app/en/docs/features)
📍 Практические руководства: [развернуть мультиплатформенного ИИ-бота за 5 минут](https://blog.langbot.app/en/blog/deploy-ai-bot-in-5-minutes/), [подключить DeepSeek к WeChat, Discord и Telegram](https://blog.langbot.app/en/blog/connect-deepseek-to-wechat/), [запустить Dify Agent в Discord, Telegram и Slack](https://blog.langbot.app/en/blog/dify-agent-discord-telegram-slack/) и [создать чат-бота на n8n](https://blog.langbot.app/en/blog/n8n-multi-platform-ai-chatbot/).
📍 Практические руководства: [развернуть мультиплатформенного ИИ-бота за 5 минут](https://langbot.app/en/blog/deploy-ai-bot-in-5-minutes/), [подключить DeepSeek к WeChat, Discord и Telegram](https://langbot.app/en/blog/connect-deepseek-to-wechat/), [запустить Dify Agent в Discord, Telegram и Slack](https://langbot.app/en/blog/dify-agent-discord-telegram-slack/) и [создать чат-бота на n8n](https://langbot.app/en/blog/n8n-multi-platform-ai-chatbot/).
---
@@ -91,6 +91,16 @@ docker compose --profile all up -d
---
## Демо
**Попробуйте прямо сейчас:** https://demo.langbot.dev/
- Email: `demo@langbot.app`
- Пароль: `langbot123456`
*Примечание: Публичная демо-среда. Не вводите конфиденциальную информацию.*
---
## Поддерживаемые платформы
| Платформа | Статус | Примечания |
@@ -131,7 +141,7 @@ docker compose --profile all up -d
| [Volc Engine Ark](https://console.volcengine.com/ark/region:ark+cn-beijing/model?vendor=Bytedance&view=LIST_VIEW) | Шлюз | ✅ |
| [ModelScope](https://modelscope.cn/docs/model-service/API-Inference/intro) | Шлюз | ✅ |
| [GiteeAI](https://ai.gitee.com/) | Шлюз | ✅ |
| [302.AI](https://share.302.ai/SuTG99) | Шлюз | ✅ |
| [302.AI](https://share.302ai.cn/SuTG99) | Шлюз | ✅ |
| [接口 AI](https://jiekou.ai/) | Шлюз | ✅ |
| [CompShare](https://www.compshare.cn/?ytag=GPU_YY-gh_langbot) | Платформа GPU | ✅ |
| [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | Платформа GPU | ✅ |
@@ -153,14 +163,6 @@ docker compose --profile all up -d
---
## Демо
**Попробуйте прямо сейчас:** https://demo.langbot.dev/
- Email: `demo@langbot.app`
- Пароль: `langbot123456`
*Примечание: Публичная демо-среда. Не вводите конфиденциальную информацию.*
## Создано для ИИ-агентов 🤖
LangBot **дружелюбен к агентам по своей архитектуре** —— ваши кодинг-агенты (Claude Code, Codex, Copilot, Cursor и др.) могут управлять, расширять и развёртывать LangBot с первоклассной поддержкой:
@@ -182,12 +184,6 @@ LangBot **дружелюбен к агентам по своей архитек
---
## История Stars
[![Star History Chart](https://api.star-history.com/svg?repos=langbot-app/LangBot&type=Date)](https://star-history.com/#langbot-app/LangBot&Date)
---
## Участники
Спасибо всем [участникам](https://github.com/langbot-app/LangBot/graphs/contributors), которые помогли сделать LangBot лучше:
+12 -16
View File
@@ -52,7 +52,7 @@ LangBot 是一個**開源的生產級平台**,用於建構 AI 驅動的即時
[→ 了解更多功能特性](https://link.langbot.app/zh/docs/features)
📍 實踐指南:[5 分鐘部署多平台 AI 機器人](https://blog.langbot.app/zh/blog/deploy-ai-bot-in-5-minutes/)、[將 DeepSeek 接入微信、企業微信與 Discord](https://blog.langbot.app/zh/blog/connect-deepseek-to-wechat/)、[讓 Dify Agent 跑在 Discord、Telegram 和 Slack 上](https://blog.langbot.app/zh/blog/dify-agent-discord-telegram-slack/),以及[用 n8n 建構多平台 AI 聊天機器人](https://blog.langbot.app/zh/blog/n8n-multi-platform-ai-chatbot/)。
📍 實踐指南:[5 分鐘部署多平台 AI 機器人](https://langbot.app/zh/blog/deploy-ai-bot-in-5-minutes/)、[將 DeepSeek 接入微信、企業微信與 Discord](https://langbot.app/zh/blog/connect-deepseek-to-wechat/)、[讓 Dify Agent 跑在 Discord、Telegram 和 Slack 上](https://langbot.app/zh/blog/dify-agent-discord-telegram-slack/),以及[用 n8n 建構多平台 AI 聊天機器人](https://langbot.app/zh/blog/n8n-multi-platform-ai-chatbot/)。
---
@@ -93,6 +93,16 @@ docker compose --profile all up -d
---
## 線上演示
**立即體驗:** https://demo.langbot.dev/
- 信箱:`demo@langbot.app`
- 密碼:`langbot123456`
*注意:公開演示環境,請不要在其中填入任何敏感資訊。*
---
## 支援的平台
| 平台 | 狀態 | 備註 |
@@ -137,7 +147,7 @@ docker compose --profile all up -d
| [優雲智算](https://www.compshare.cn/?ytag=GPU_YY-gh_langbot) | GPU 平台 | ✅ |
| [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | GPU 平台 | ✅ |
| [接口 AI](https://jiekou.ai/) | 聚合平台 | ✅ |
| [302.AI](https://share.302.ai/SuTG99) | 聚合平台 | ✅ |
| [302.AI](https://share.302ai.cn/SuTG99) | 聚合平台 | ✅ |
| [Qiniu](https://www.qiniu.com/ai/agent) | 聚合平台 | ✅ |
### TTS(語音合成)
@@ -169,14 +179,6 @@ docker compose --profile all up -d
---
## 線上演示
**立即體驗:** https://demo.langbot.dev/
- 信箱:`demo@langbot.app`
- 密碼:`langbot123456`
*注意:公開演示環境,請不要在其中填入任何敏感資訊。*
## 為 AI Agent 而生 🤖
LangBot **從設計上就對 Agent 友善** —— 你的編碼 AgentClaude Code、Codex、Copilot、Cursor 等)可以一等公民般地操作、擴充和部署 LangBot:
@@ -200,12 +202,6 @@ LangBot **從設計上就對 Agent 友善** —— 你的編碼 AgentClaude C
---
## Star 趨勢
[![Star History Chart](https://api.star-history.com/svg?repos=langbot-app/LangBot&type=Date)](https://star-history.com/#langbot-app/LangBot&Date)
---
## 貢獻者
感謝所有[貢獻者](https://github.com/langbot-app/LangBot/graphs/contributors)對 LangBot 的幫助:
+13 -17
View File
@@ -5,7 +5,7 @@
<div align="center">
<a href="https://www.producthunt.com/products/langbot?utm_source=badge-follow&utm_medium=badge&utm_source=badge-langbot" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/follow.svg?product_id=1077185&theme=light" alt="LangBot - Production&#0045;grade&#0032;IM&#0032;bot&#0032;made&#0032;easy&#0046; | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
<a href="https://www.producthunt.com/products/langbot/launches/langbot?embed=true&amp;utm_source=badge-featured&amp;utm_medium=badge&amp;utm_campaign=badge-langbot" target="_blank" rel="noopener noreferrer"><img alt="LangBot - Easy-to-use global IM bot platform designed for the LLM era | Product Hunt" width="250" height="54" src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=979554&amp;theme=light&amp;t=1782822143403"></a>
<h3>Nền tảng cấp sản xuất để xây dựng bot IM với AI agent.</h3>
<h4>Xây dựng, gỡ lỗi và triển khai bot AI nhanh chóng trên Slack, Discord, Telegram, WeChat và nhiều nền tảng khác.</h4>
@@ -50,7 +50,7 @@ LangBot là một **nền tảng mã nguồn mở, cấp sản xuất** để x
[→ Tìm hiểu thêm về tất cả tính năng](https://link.langbot.app/en/docs/features)
📍 Hướng dẫn thực hành: [triển khai bot AI đa nền tảng trong 5 phút](https://blog.langbot.app/en/blog/deploy-ai-bot-in-5-minutes/), [kết nối DeepSeek với WeChat, Discord và Telegram](https://blog.langbot.app/en/blog/connect-deepseek-to-wechat/), [chạy Dify Agent trên Discord, Telegram và Slack](https://blog.langbot.app/en/blog/dify-agent-discord-telegram-slack/) và [xây dựng chatbot với n8n](https://blog.langbot.app/en/blog/n8n-multi-platform-ai-chatbot/).
📍 Hướng dẫn thực hành: [triển khai bot AI đa nền tảng trong 5 phút](https://langbot.app/en/blog/deploy-ai-bot-in-5-minutes/), [kết nối DeepSeek với WeChat, Discord và Telegram](https://langbot.app/en/blog/connect-deepseek-to-wechat/), [chạy Dify Agent trên Discord, Telegram và Slack](https://langbot.app/en/blog/dify-agent-discord-telegram-slack/) và [xây dựng chatbot với n8n](https://langbot.app/en/blog/n8n-multi-platform-ai-chatbot/).
---
@@ -91,6 +91,16 @@ docker compose --profile all up -d
---
## Demo trực tuyến
**Thử ngay:** https://demo.langbot.dev/
- Email: `demo@langbot.app`
- Mật khẩu: `langbot123456`
*Lưu ý: Môi trường demo công khai. Không nhập thông tin nhạy cảm.*
---
## Nền tảng được hỗ trợ
| Nền tảng | Trạng thái | Ghi chú |
@@ -135,7 +145,7 @@ docker compose --profile all up -d
| [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | Nền tảng GPU | ✅ |
| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | Nền tảng GPU | ✅ |
| [接口 AI](https://jiekou.ai/) | Cổng | ✅ |
| [302.AI](https://share.302.ai/SuTG99) | Cổng | ✅ |
| [302.AI](https://share.302ai.cn/SuTG99) | Cổng | ✅ |
| [Qiniu](https://www.qiniu.com/ai/agent) | Cổng | ✅ |
[→ Xem tất cả tích hợp](https://link.langbot.app/en/docs/features)
@@ -153,14 +163,6 @@ docker compose --profile all up -d
---
## Demo trực tuyến
**Thử ngay:** https://demo.langbot.dev/
- Email: `demo@langbot.app`
- Mật khẩu: `langbot123456`
*Lưu ý: Môi trường demo công khai. Không nhập thông tin nhạy cảm.*
## Được xây dựng cho AI Agent 🤖
LangBot **thân thiện với agent ngay từ thiết kế** —— các coding agent của bạn (Claude Code, Codex, Copilot, Cursor, …) có thể vận hành, mở rộng và triển khai LangBot với sự hỗ trợ hạng nhất:
@@ -182,12 +184,6 @@ LangBot **thân thiện với agent ngay từ thiết kế** —— các coding
---
## Lịch sử Star
[![Star History Chart](https://api.star-history.com/svg?repos=langbot-app/LangBot&type=Date)](https://star-history.com/#langbot-app/LangBot&Date)
---
## Người đóng góp
Cảm ơn tất cả [người đóng góp](https://github.com/langbot-app/LangBot/graphs/contributors) đã giúp LangBot trở nên tốt hơn:
+171
View File
@@ -0,0 +1,171 @@
# Valkey Search Vector Database Integration
This document describes how to use **Valkey Search** (the search/vector module bundled in
`valkey/valkey-bundle`) as the vector database backend for LangBot's knowledge base (RAG)
feature.
## What is Valkey Search?
**Valkey Search** is a module that adds vector similarity search and full-text search to
[Valkey](https://valkey.io/), the open-source, BSD-licensed in-memory data store forked from
Redis OSS. It is distributed in the `valkey/valkey-bundle` image alongside other modules
(JSON, Bloom, LDAP).
LangBot talks to Valkey through the official [`valkey-glide`](https://pypi.org/project/valkey-glide/)
client (Rust core + async Python wrapper), using its native `ft` (search) command namespace.
### Key Features
- **Vector search**: ANN via HNSW or exact via FLAT, with COSINE / L2 / IP distance metrics
- **Full-text search**: term, prefix and phrase matching over indexed text fields
- **Hybrid search**: a metadata/text filter pre-selects candidates, then KNN ranks them
- **In-memory speed**: vectors and documents are stored as Valkey HASH keys
- **Auth + TLS**: optional username/password and TLS for production (toB / SaaS) deployments
### Licensing
- Valkey core and the Search module are **BSD-3-Clause**.
- The `valkey-glide` client is **Apache-2.0**.
Both are compatible with LangBot.
## Installation
Valkey Search support is included automatically on Linux and macOS. The official `valkey-glide`
client does not currently publish a Windows package, so LangBot skips this optional dependency on
Windows; LangBot remains usable there, but the Valkey Search backend is unavailable. To install the
client manually on a supported platform:
```bash
pip install 'valkey-glide>=2.4.1,<3.0.0'
```
You also need a running Valkey server with the Search module loaded. The simplest way is the
bundled image:
```bash
# Run valkey-bundle (includes the Search module) on host port 6380
podman run -d --name valkey-test-langbot -p 6380:6379 valkey/valkey-bundle:9.1.0
# (docker run ... works identically)
```
`valkey-bundle` ships multi-arch images (linux/amd64 + linux/arm64), so it runs on both CI
(x86_64) and Apple-silicon dev machines.
## Configuration
Valkey Search is **opt-in and disabled by default** — the default `vdb.use` stays `chroma`,
so existing single-process deployments are unaffected. To enable it, edit your `config.yaml`:
```yaml
vdb:
use: valkey_search
valkey_search:
host: 'localhost'
port: 6379 # use 6380 if you started the container as shown above
db: 0
password: '' # optional (ACL / requirepass) — never logged
username: '' # optional (ACL user)
tls: false # optional (toB / SaaS)
index_algorithm: 'HNSW' # HNSW | FLAT
distance_metric: 'COSINE' # COSINE | L2 | IP
request_timeout: 5000 # per-request timeout in ms
```
| Option | Default | Description |
|--------|---------|-------------|
| `host` | `localhost` | Valkey host |
| `port` | `6379` | Valkey port |
| `db` | `0` | Logical database id |
| `password` | `''` | Optional auth password (empty = no auth). Never logged. |
| `username` | `''` | Optional ACL username. Configuring a username without a password fails closed (raises) rather than connecting unauthenticated. |
| `tls` | `false` | Enable TLS for the connection |
| `index_algorithm` | `HNSW` | `HNSW` (approximate) or `FLAT` (exact) |
| `distance_metric` | `COSINE` | `COSINE`, `L2`, or `IP` |
| `request_timeout` | `5000` | Per-request timeout in milliseconds. The valkey-glide default (250ms) is too low for vector KNN under load; raise it further for remote/cross-AZ Valkey. |
### Connection behavior
The backend uses a **lazy** connection (`lazy_connect=True`): the client is created on first
use and the connection is deferred to the first command. A misconfigured or unreachable Valkey
server therefore does **not** block LangBot from booting — knowledge-base operations will error
at call time instead, and you can recover by switching `vdb.use` back to another backend.
The connection sets a fixed `client_name` of `langbot_vector_client` so it is identifiable in
`CLIENT LIST` and monitoring dashboards.
## Supported search types
| Type | Behavior |
|------|----------|
| `vector` | Pure KNN over the embedding field |
| `full_text` | Term/phrase match over the indexed `document` text field |
| `hybrid` | Metadata/text filter **pre-selects** candidates, then KNN ranks them |
### ⚠️ Important: `vector_weight` is NOT honored
Valkey Search hybrid queries follow a **filter-then-KNN** model: the filter (and/or full-text
clause) narrows the candidate set, and the KNN stage ranks the survivors by vector distance.
There is **no native weighted score fusion** (unlike, e.g., SeekDB's RRF boost).
For interface compatibility the backend still accepts a `vector_weight` argument, but it is
**ignored** — passing different weights does not change result ordering. The first time a
non-default weight is supplied, the backend logs a one-time warning.
If weighted hybrid ranking is needed in the future, it can be added **application-side** (run
vector KNN and full-text search separately and blend the scores). That is intentionally out of
scope for this integration.
## Metadata & filtering
Documents are stored as Valkey HASH keys under the prefix `kb:{collection}:{id}` with fields:
- `vector` — the embedding, packed as little-endian FLOAT32
- `document` — the raw text (indexed as TEXT for full-text/hybrid search)
- `file_id` — promoted to an indexed TAG field so it is filterable
- `metadata_json` — the full metadata dict, preserved verbatim as JSON
Only **indexed** fields are filterable. Currently that is `file_id`. Filters referencing
non-indexed metadata keys are dropped with a warning (the same pragmatism used by the Milvus
and pgvector backends). All other metadata still round-trips intact via `metadata_json`.
Supported filter operators (canonical Chroma-style `where` syntax): `$eq`, `$ne`, `$gt`,
`$gte`, `$lt`, `$lte`, `$in`, `$nin`. Multiple top-level keys are AND-ed.
## Testing
Unit tests (filter mapping, float32 packing, reply parsing, import guard) run in the fast lane
with no server:
```bash
uv run pytest tests/unit_tests/vector/test_valkey_search_filter.py -q
```
Integration tests are **slow-gated** on `TEST_VALKEY_URL` and require a running server:
```bash
podman run -d --name valkey-test-langbot -p 6380:6379 valkey/valkey-bundle:9.1.0
TEST_VALKEY_URL=valkey://localhost:6380 \
uv run pytest tests/integration/vector/test_valkey_search.py -m slow -q
```
The default upstream fast CI lane (`-m "not slow"`) skips these, matching the existing
PostgreSQL migration-test precedent.
## Troubleshooting
| Symptom | Cause / fix |
|---------|-------------|
| Tests skip with "Valkey Search module not available" | The server is plain Valkey without the Search module. Use the `valkey/valkey-bundle` image. |
| `ConnectionError` at call time | Check `host`/`port`/auth; remember `lazy_connect` defers errors to first use. |
| Empty search results right after insert | The Search indexer is asynchronous; results become visible within a short delay. The integration tests poll/retry to account for this. |
| Hybrid ranking ignores `vector_weight` | Expected — see the caveat above. |
## Production considerations
- **Cluster mode**: Valkey Search in cluster mode uses an additional coordination port. This
integration targets standalone mode; cluster support is a future consideration.
- **Persistence**: configure Valkey RDB/AOF persistence if the knowledge base must survive
restarts; otherwise an in-memory store is ephemeral.
- **Security**: set `password`/`username` and `tls: true` for any non-local deployment.
Credentials are never written to logs.
Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

+196
View File
@@ -0,0 +1,196 @@
# MCP Resources PR #2215 Review
> 更新日期: 2026-06-29
> 分支: `mcp_resources`
> PR: langbot-app/LangBot#2215
> 主题: MCP Resources 在 LangBot 中的产品价值、AgentRunner 集成方式与后续架构方向
## 结论
PR #2215 对 LangBot 有明确价值:它补齐了 MCP 协议中 Resources 这一重要能力,让 MCP server 不再只暴露 tools,也可以暴露文档、代码片段、配置、日志、图片等上下文资源。管理端可以发现和预览资源,Agent 也可以通过当前实现按需列出和读取资源。
但当前 AgentRunner 层的接入方式更接近一个可用的第一阶段方案,而不是最终架构。现在 MCP Resources 被包装成两个 synthetic tools
- `langbot_mcp_list_resources`
- `langbot_mcp_read_resource`
这让模型可以通过 function calling 主动探索资源,落地成本低,也复用了已有 `ToolManager` / `LocalAgentRunner` 的工具调用链路。不过从 MCP 规范和主流实现来看,Resources 更适合作为一种一等上下文来源,而不是长期隐藏在工具列表里。
建议保留当前 synthetic tools 作为探索能力,同时把后续主线设计调整为:MCP Resources 是 pipeline / conversation / message 级别可选择、可固定、可审计的上下文输入。
## 当前实现判断
当前 AgentRunner 集成路径如下:
```text
Pipeline 绑定 MCP server
-> query.variables['_pipeline_bound_mcp_servers']
-> Preproc 为 local-agent 加载工具
-> ToolManager.get_all_tools()
-> MCPLoader 注入 synthetic resource tools
-> LocalAgentRunner 将工具 schema 传给模型
-> 模型发起 list/read tool call
-> ToolManager.execute_func_call()
-> MCPLoader 调 MCP session.list_resources/read_resource
-> tool result 回灌给模型
```
这个路径的优点是:
- 复用现有工具调用机制,改动范围小。
- Agent 可以按需探索资源,不需要每轮预先读取所有资源。
- 可以沿用 pipeline 绑定的 MCP server 范围,避免越权读取未绑定 server。
- 对已有 MCP tools 行为影响较小。
主要问题是:
- Resources 在语义上被降级成 tools,和 MCP 规范里的 resource primitive 不完全一致。
- 模型必须先理解并主动调用 `list/read`,资源不会自然成为上下文。
- pipeline 不能配置“默认携带某些资源”或“本轮附加某些资源”。
- UI 资源 tab 目前是管理端预览能力,和 Agent 上下文选择没有打通。
- 对 blob、图片、大文件、结构化资源的处理还比较粗糙。
- 缺少 resource templates、订阅更新、缓存、chunk、token budget、trace 与审计策略。
## 主流项目做法
### MCP 官方规范
MCP Resources 是 server 暴露上下文数据的协议能力。规范没有要求 resources 必须以 tool call 形式给模型使用,而是把如何选择、过滤、读取和纳入上下文交给 Host application。
这意味着比较正统的集成方式是:LangBot 作为 Host,在 pipeline、会话或消息层决定哪些 resources 进入模型上下文。
参考: https://modelcontextprotocol.io/specification/2025-06-18/server/resources
### VS Code Copilot
VS Code 把 MCP Resources 做成 chat context 的一部分。用户可以通过 `Add Context > MCP Resources` 或命令浏览 MCP resources,并把选中的资源附加到一次 chat request。
这是目前最值得 LangBot 参考的产品形态:资源不是模型工具,而是用户和 Host 可控的上下文附件。
参考: https://code.visualstudio.com/docs/agent-customization/mcp-servers
### Anthropic SDK
Anthropic 的 client-side MCP helpers 提供资源读取和转换能力,例如把 MCP resource 转为 Claude message content 或 file。也就是说,应用先读取 resource,再显式放进模型消息。
这同样是 application-owned context injection,而不是把 resource 伪装成模型工具。
参考: https://platform.claude.com/docs/en/agents-and-tools/mcp-connector
### LangChain MCP Adapters
LangChain 把 MCP Resources 更像 data loader / document input 来处理,可以把资源加载成 `Blob`,再进入 LangChain 的文档、检索或上下文处理链路。
这说明 Resources 很适合作为知识源、文档源或上下文源,而不只是即时工具调用。
参考: https://docs.langchain.com/oss/python/langchain/mcp
### OpenAI Agents SDK
OpenAI Agents SDK 主路径仍偏向 MCP tools,但底层 MCP server API 已经有 `list_resources``list_resource_templates``read_resource` 等能力。当前形态说明 resources 是 client 能力,但并未默认变成 agent-visible tools。
参考: https://openai.github.io/openai-agents-python/mcp/
### Cline
Cline 会拉取 MCP tools、resources、resourceTemplates、prompts,并通过类似 `access_mcp_resource` 的内置访问方式让模型读取资源。这个方向和 LangBot 当前 synthetic tools 比较接近。
这种模式适合让 Agent 自主探索,但更像 Host 自定义的模型访问协议,不应成为唯一集成路径。
参考: https://github.com/cline/cline/blob/main/src/services/mcp/McpHub.ts
## 建议架构方向
### 1. 保留探索型工具
保留当前两个 synthetic tools
- `langbot_mcp_list_resources`
- `langbot_mcp_read_resource`
它们适合处理“用户没有显式选择资源,但 Agent 判断需要探索 MCP server 上下文”的场景。后续可以优化工具描述、返回格式、资源大小限制和错误信息。
### 2. 增加一等 Resource Context
新增一个 Host 层资源上下文概念,例如:
```text
PipelineResourceBinding
ConversationResourceAttachment
MessageResourceAttachment
```
Preproc 或独立的 `ResourceContextProvider` 在模型调用前读取这些资源,按 MIME 类型、大小、token budget 转为模型可消费的上下文。
### 3. 打通 UI 与 Agent 上下文
当前 MCP 详情页的 Resources tab 可以继续作为资源发现和预览入口。建议增加操作:
- 添加到本轮上下文
- 固定到当前 pipeline
- 固定到当前 bot / conversation
- 查看资源读取历史和错误
这样 UI 资源管理能力才能真正影响 Agent 行为。
### 4. 支持 resource templates
MCP resource templates 允许 server 暴露参数化资源,例如:
```text
repo://{owner}/{repo}/file/{path}
log://{service}/{date}
```
LangBot 后续应支持模板发现、参数填写、实例化和绑定。否则只能使用静态 resources,覆盖面会受限。
### 5. 增加资源处理策略
建议补齐:
- 文本资源 token budget 与截断策略。
- 大文件 chunk 与摘要策略。
- 图片/blob 的模型能力判断与 fallback。
- MIME 类型白名单与安全限制。
- 缓存与过期策略。
- `resources/listChanged` 或订阅更新。
- resource read trace,便于审计 Agent 读取了什么上下文。
## 推荐落地顺序
### Phase 1: 完成当前 PR 可用性
- 保留 synthetic tools。
- 明确文档说明当前 Agent 集成是 tool-mediated。
- 完善资源工具描述,降低模型误用概率。
- 给 read/list 增加大小限制和更清晰的 MIME 处理。
- 前端 Resources tab 与 Tools tab 分离,保持管理端清晰。
### Phase 2: 做 Host-owned context attachments
- 在 pipeline 或 conversation 层新增 resource attachment 配置。
- Preproc 读取已绑定 resources,注入模型上下文。
- UI 支持“添加到上下文 / 固定到 pipeline”。
- 记录每轮实际注入的 resource URI 和 token 消耗。
### Phase 3: 做完整 MCP Resources 能力
- 支持 resource templates。
- 支持资源订阅更新。
- 支持 chunk、summary、RAG 化接入。
- 为 DifyAgentRunner、LocalAgentRunner 等不同 runner 定义统一资源上下文接口。
## 最终建议
PR #2215 可以作为 MCP Resources 的第一阶段实现继续推进。它让 LangBot 快速拥有“资源发现、预览、按需读取”的闭环,也给 Agent 探索资源提供了可运行路径。
但在正式设计上,不建议把 “Resources == Tools” 固化为长期抽象。LangBot 更应该把 MCP Resources 定位为上下文来源,与 tools、prompts、knowledge base 并列:
```text
Tools -> Agent 可以执行的动作
Resources -> Host/用户/Agent 可以选择的上下文数据
Prompts -> 可复用的任务模板
Knowledge -> 可检索、可索引的长期知识
```
这样既尊重 MCP 协议语义,也能让 LangBot 在 Agent 工作流、企业知识接入和多 MCP server 管理上走得更稳。
+3 -2
View File
@@ -1,6 +1,6 @@
[project]
name = "langbot"
version = "4.10.4"
version = "4.10.6"
description = "Production-grade platform for building agentic IM bots"
readme = "README.md"
license-files = ["LICENSE"]
@@ -70,7 +70,7 @@ dependencies = [
"chromadb>=1.0.0,<2.0.0",
"qdrant-client (>=1.15.1,<2.0.0)",
"pyseekdb==1.1.0.post3",
"langbot-plugin==0.4.6",
"langbot-plugin==0.4.13",
"asyncpg>=0.30.0",
"line-bot-sdk>=3.19.0",
"matrix-nio>=0.25.2",
@@ -80,6 +80,7 @@ dependencies = [
"pgvector>=0.4.1",
"botocore>=1.42.39",
"litellm>=1.0.0",
"valkey-glide>=2.4.1,<3.0.0; sys_platform != 'win32'", # No Windows wheels are published
]
keywords = [
"bot",
File diff suppressed because it is too large Load Diff
Regular → Executable
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
@@ -109,6 +109,62 @@ class AsyncDifyServiceClient:
if chunk.startswith('data:'):
yield json.loads(chunk[5:])
async def workflow_submit(
self,
form_token: str,
workflow_run_id: str,
inputs: dict[str, typing.Any],
user: str,
action: str = '',
timeout: float = 120.0,
) -> typing.AsyncGenerator[dict[str, typing.Any], None]:
"""Submit human input to resume a paused workflow, then stream events.
1. POST /form/human_input/{form_token} to submit the form
2. GET /workflow/{task_id}/events to stream the resumed workflow events
"""
headers = {
'Authorization': f'Bearer {self.api_key}',
'Content-Type': 'application/json',
}
async with httpx.AsyncClient(
base_url=self.base_url,
trust_env=True,
timeout=timeout,
) as client:
# Step 1: Submit the form
payload: dict[str, typing.Any] = {
'inputs': inputs if isinstance(inputs, dict) else {},
'user': user,
'action': action,
}
submit_resp = await client.post(
f'/form/human_input/{form_token}',
headers=headers,
json=payload,
)
if submit_resp.status_code != 200:
raise DifyAPIError(f'{submit_resp.status_code} {submit_resp.text}')
# Step 2: Stream resumed workflow events
async with client.stream(
'GET',
f'/workflow/{workflow_run_id}/events',
headers={'Authorization': f'Bearer {self.api_key}'},
params={'user': user},
) as r:
if r.status_code != 200:
body = (await r.aread()).decode(errors='replace')
raise DifyAPIError(f'{r.status_code} {body}')
async for chunk in r.aiter_lines():
if chunk.strip() == '':
continue
if chunk.startswith('data:'):
yield json.loads(chunk[5:])
async def upload_file(
self,
file: httpx._types.FileTypes,
+389 -26
View File
@@ -1,17 +1,48 @@
import asyncio
import base64
import json
import logging
import os
import time
import typing
import uuid
import urllib.parse
from typing import Callable
from typing import Awaitable, Callable, Optional
import dingtalk_stream # type: ignore
import websockets
from .EchoHandler import EchoTextHandler
from .card_callback import DingTalkCardActionHandler
from .dingtalkevent import DingTalkEvent
import httpx
import traceback
_stdout_logger = logging.getLogger('langbot.dingtalk_api')
DINGTALK_OPENAPI_BASE = 'https://api.dingtalk.com'
def _stringify_card_param_map(card_param_map: Optional[dict]) -> dict:
"""DingTalk cardParamMap only accepts string values.
Keep callers free to pass structured values for template variables such
as button groups or select options, then encode them once at the API
boundary.
"""
if not card_param_map:
return {}
result = {}
for key, value in card_param_map.items():
if value is None:
result[key] = ''
elif isinstance(value, str):
result[key] = value
else:
result[key] = json.dumps(value, ensure_ascii=False)
return result
class DingTalkClient:
def __init__(
self,
@@ -21,6 +52,7 @@ class DingTalkClient:
robot_code: str,
markdown_card: bool,
logger: None,
card_action_callback: Optional[Callable[[dict], Awaitable[None]]] = None,
):
"""初始化 WebSocket 连接并自动启动"""
self.credential = dingtalk_stream.Credential(client_id, client_secret)
@@ -30,6 +62,14 @@ class DingTalkClient:
# 在 DingTalkClient 中传入自己作为参数,避免循环导入
self.EchoTextHandler = EchoTextHandler(self)
self.client.register_callback_handler(dingtalk_stream.chatbot.ChatbotMessage.TOPIC, self.EchoTextHandler)
# STREAM-mode card action button click handler. Forwards parsed payload
# to the adapter so it can resume paused Dify workflows.
self.card_action_callback = card_action_callback
self.card_action_handler = DingTalkCardActionHandler(self.client, self._on_card_action)
self.client.register_callback_handler(
dingtalk_stream.handlers.CallbackHandler.TOPIC_CARD_CALLBACK,
self.card_action_handler,
)
self._message_handlers = {
'example': [],
}
@@ -39,8 +79,24 @@ class DingTalkClient:
self.access_token_expiry_time = ''
self.markdown_card = markdown_card
self.logger = logger
# Legacy access_token used by the OLD oapi.dingtalk.com endpoints
# (e.g. /media/upload, which is the only documented way to get an
# `@xxx` media_id usable in card Avatar.imageUrl). The new v1.0
# token doesn't work there — different auth domain.
self.legacy_access_token = ''
self.legacy_access_token_expiry_time: typing.Optional[float] = None
self._stopped = False # Flag to control the event loop
async def _on_card_action(self, payload: dict) -> None:
"""Dispatch a parsed card-action payload to the adapter callback."""
if self.card_action_callback is None:
return
try:
await self.card_action_callback(payload)
except Exception:
if self.logger:
await self.logger.error(f'DingTalk card action callback error: {traceback.format_exc()}')
async def get_access_token(self):
url = 'https://api.dingtalk.com/v1.0/oauth2/accessToken'
headers = {'Content-Type': 'application/json'}
@@ -429,18 +485,35 @@ class DingTalkClient:
'Content-Type': 'application/json',
}
# For enterprise-internal robots, robotCode == AppKey (client_id).
# The dedicated robot_code field is only required for scenario-group
# robots or third-party robots; fall back to client_id when empty so
# the common single-bot setup keeps working without manual config.
robot_code = self.robot_code or self.key
data = {
'robotCode': self.robot_code,
'robotCode': robot_code,
'userIds': [target_id],
'msgKey': 'sampleText',
'msgParam': json.dumps({'content': content}),
}
_stdout_logger.info(
'DingTalk send_proactive_message_to_one request: robotCode=%s target_id=%s content_len=%d',
robot_code,
target_id,
len(content),
)
try:
async with httpx.AsyncClient() as client:
response = await client.post(url, headers=headers, json=data)
_stdout_logger.info(
'DingTalk send_proactive_message_to_one response: status=%d body=%s',
response.status_code,
response.text[:500],
)
if response.status_code == 200:
return
except Exception:
_stdout_logger.exception('DingTalk send_proactive_message_to_one error')
await self.logger.error(f'failed to send proactive massage to person: {traceback.format_exc()}')
raise Exception(f'failed to send proactive massage to person: {traceback.format_exc()}')
@@ -456,7 +529,7 @@ class DingTalkClient:
}
data = {
'robotCode': self.robot_code,
'robotCode': self.robot_code or self.key,
'openConversationId': target_id,
'msgKey': 'sampleText',
'msgParam': json.dumps({'content': content}),
@@ -477,47 +550,334 @@ class DingTalkClient:
quote_origin: bool = False,
card_auto_layout: bool = False,
):
card_data = {}
card_data['config'] = json.dumps({'autoLayout': card_auto_layout})
card_data['content'] = ''
"""Create + deliver the streaming chat card for a chatbot reply.
# 将用户的消息内容作为卡片的查询参数,方便后续处理
if incoming_message.message_type == 'text':
card_data['query'] = incoming_message.get_text_list()[0]
Replaces the old `dingtalk_stream.AICardReplier`-based path. Returns
`(None, out_track_id)` to keep call sites compatible with the
previous `(card_instance, card_instance_id)` shape the first slot
is unused now that everything is driven by out_track_id.
"""
out_track_id = uuid.uuid4().hex
is_group = str(incoming_message.conversation_type) == '2'
if is_group:
open_space_id = f'dtv1.card//IM_GROUP.{incoming_message.conversation_id}'
else:
card_data['query'] = '...'
open_space_id = f'dtv1.card//IM_ROBOT.{incoming_message.sender_staff_id}'
card_instance = dingtalk_stream.AICardReplier(self.client, incoming_message)
# print(card_instance)
# 先投放卡片: https://open.dingtalk.com/document/orgapp/create-and-deliver-cards
card_instance_id = await card_instance.async_create_and_deliver_card(
temp_card_id,
card_data,
card_param_map = {'content': ''}
if incoming_message.message_type == 'text':
card_param_map['query'] = incoming_message.get_text_list()[0]
else:
card_param_map['query'] = '...'
await self.create_and_deliver_card(
card_template_id=temp_card_id,
out_track_id=out_track_id,
open_space_id=open_space_id,
is_group=is_group,
card_param_map=card_param_map,
card_data_config={'autoLayout': card_auto_layout},
)
return card_instance, card_instance_id
return None, out_track_id
async def send_card_message(self, card_instance, card_instance_id: str, content: str, is_final: bool):
content_key = 'content'
"""Stream a single chunk into an existing card's `content` field."""
try:
await card_instance.async_streaming(
card_instance_id,
content_key=content_key,
await self.streaming_update_card(
out_track_id=card_instance_id,
content_key='content',
content_value=content,
append=False,
finished=is_final,
failed=False,
)
except Exception as e:
self.logger.exception(e)
await card_instance.async_streaming(
card_instance_id,
content_key=content_key,
if self.logger:
self.logger.exception(e)
await self.streaming_update_card(
out_track_id=card_instance_id,
content_key='content',
content_value='',
append=False,
finished=is_final,
failed=True,
)
async def create_and_deliver_card(
self,
*,
card_template_id: str,
out_track_id: str,
open_space_id: str,
is_group: bool,
card_param_map: Optional[dict] = None,
callback_type: str = 'STREAM',
callback_route_key: Optional[str] = None,
support_forward: bool = True,
dynamic_data_source_configs: Optional[list] = None,
card_data_config: Optional[dict] = None,
at_user_ids: Optional[dict] = None,
recipients: Optional[list] = None,
) -> bool:
"""POST /v1.0/card/instances/createAndDeliver.
Mirrors the SDK's `async_create_and_deliver_card` shape but exposes
the dynamic-data-source config slot so we can register a pull URL
for variable-length button lists.
"""
if not await self.check_access_token():
await self.get_access_token()
cardData: dict = {'cardParamMap': _stringify_card_param_map(card_param_map)}
if card_data_config is not None:
cardData['config'] = json.dumps(card_data_config)
body: dict = {
'cardTemplateId': card_template_id,
'outTrackId': out_track_id,
'cardData': cardData,
'callbackType': callback_type,
'openSpaceId': open_space_id,
'imGroupOpenSpaceModel': {'supportForward': support_forward},
'imRobotOpenSpaceModel': {'supportForward': support_forward},
}
if callback_type == 'HTTP' and callback_route_key:
body['callbackRouteKey'] = callback_route_key
if is_group:
deliver: dict = {'robotCode': self.robot_code or self.key}
if at_user_ids:
deliver['atUserIds'] = at_user_ids
if recipients is not None:
deliver['recipients'] = recipients
body['imGroupOpenDeliverModel'] = deliver
else:
body['imRobotOpenDeliverModel'] = {'spaceType': 'IM_ROBOT'}
if dynamic_data_source_configs:
body['openDynamicDataConfig'] = {'dynamicDataSourceConfigs': dynamic_data_source_configs}
url = f'{DINGTALK_OPENAPI_BASE}/v1.0/card/instances/createAndDeliver'
headers = {
'x-acs-dingtalk-access-token': self.access_token,
'Content-Type': 'application/json',
}
try:
_stdout_logger.info(
'DingTalk createAndDeliver request body: %s',
json.dumps(body, ensure_ascii=False)[:1500],
)
async with httpx.AsyncClient() as client:
response = await client.post(url, headers=headers, json=body, timeout=30.0)
if response.status_code == 200:
_stdout_logger.info(
'DingTalk createAndDeliver response: %s',
response.text[:500],
)
return True
_stdout_logger.error(
'DingTalk createAndDeliver failed: status=%s body=%s',
response.status_code,
response.text,
)
if self.logger:
await self.logger.error(
f'DingTalk createAndDeliver failed: status={response.status_code} body={response.text}'
)
return False
except Exception:
_stdout_logger.exception('DingTalk createAndDeliver error')
if self.logger:
await self.logger.error(f'DingTalk createAndDeliver error: {traceback.format_exc()}')
return False
async def streaming_update_card(
self,
*,
out_track_id: str,
content_key: str,
content_value: str,
append: bool,
finished: bool,
failed: bool = False,
) -> bool:
"""PUT /v1.0/card/streaming.
Replaces `dingtalk_stream.AICardReplier.async_streaming` same body
shape (outTrackId / guid / key / content / isFull / isFinalize /
isError) per the SDK source.
"""
if not await self.check_access_token():
await self.get_access_token()
body = {
'outTrackId': out_track_id,
'guid': uuid.uuid4().hex,
'key': content_key,
'content': content_value,
'isFull': not append,
'isFinalize': finished,
'isError': failed,
}
url = f'{DINGTALK_OPENAPI_BASE}/v1.0/card/streaming'
headers = {
'x-acs-dingtalk-access-token': self.access_token,
'Content-Type': 'application/json',
}
try:
async with httpx.AsyncClient() as client:
response = await client.put(url, headers=headers, json=body, timeout=30.0)
if response.status_code == 200:
return True
if self.logger:
await self.logger.error(
f'DingTalk card streaming failed: status={response.status_code} body={response.text}'
)
return False
except Exception:
if self.logger:
await self.logger.error(f'DingTalk card streaming error: {traceback.format_exc()}')
return False
async def update_card_data(
self,
*,
out_track_id: str,
card_param_map: Optional[dict] = None,
private_data: Optional[dict] = None,
) -> bool:
"""PUT /v1.0/card/instances — non-streaming card content update."""
if not await self.check_access_token():
await self.get_access_token()
body: dict = {
'outTrackId': out_track_id,
'cardData': {'cardParamMap': _stringify_card_param_map(card_param_map)},
}
if private_data:
body['privateData'] = private_data
url = f'{DINGTALK_OPENAPI_BASE}/v1.0/card/instances'
headers = {
'x-acs-dingtalk-access-token': self.access_token,
'Content-Type': 'application/json',
}
try:
_stdout_logger.info(
'DingTalk update_card_data request: out_track_id=%s body=%s',
out_track_id,
json.dumps(body, ensure_ascii=False)[:1500],
)
async with httpx.AsyncClient() as client:
response = await client.put(url, headers=headers, json=body, timeout=30.0)
_stdout_logger.info(
'DingTalk update_card_data response: status=%d body=%s',
response.status_code,
response.text[:300],
)
if response.status_code == 200:
return True
if self.logger:
await self.logger.error(
f'DingTalk update card failed: status={response.status_code} body={response.text}'
)
return False
except Exception:
_stdout_logger.exception('DingTalk update_card_data error')
if self.logger:
await self.logger.error(f'DingTalk update card error: {traceback.format_exc()}')
return False
async def get_legacy_access_token(self) -> Optional[str]:
"""Fetch the LEGACY (oapi.dingtalk.com) access_token. This is a
different auth domain from the v1.0 token cached in
``self.access_token`` only the legacy token authorises the
``/media/upload`` endpoint that returns an ``@xxx`` media_id
consumable by card components like Avatar.imageUrl.
Returns the token string on success, None on failure. Caches
with a 60s safety margin before the documented 7200s expiry.
"""
now = time.time()
if (
self.legacy_access_token
and self.legacy_access_token_expiry_time
and now < self.legacy_access_token_expiry_time
):
return self.legacy_access_token
url = 'https://oapi.dingtalk.com/gettoken'
try:
async with httpx.AsyncClient() as client:
response = await client.get(url, params={'appkey': self.key, 'appsecret': self.secret}, timeout=15.0)
data = response.json() if response.status_code == 200 else {}
if data.get('errcode') == 0 and data.get('access_token'):
self.legacy_access_token = data['access_token']
expires_in = int(data.get('expires_in', 7200))
self.legacy_access_token_expiry_time = now + expires_in - 60
return self.legacy_access_token
if self.logger:
await self.logger.error(
f'DingTalk legacy gettoken failed: status={response.status_code} body={response.text[:200]}'
)
except Exception:
_stdout_logger.exception('DingTalk legacy gettoken error')
if self.logger:
await self.logger.error(f'DingTalk legacy gettoken error: {traceback.format_exc()}')
return None
async def upload_image_media(self, file_path: str) -> Optional[str]:
"""Upload an image file to DingTalk media storage and return the
``@xxx`` media_id, which can be passed straight into card variables
like Avatar.imageUrl. Endpoint:
POST https://oapi.dingtalk.com/media/upload?access_token=&type=image
Returns the media_id on success, None on any failure (caller
should handle a None gracefully DingTalk falls back to a
default avatar when imageUrl is empty/unknown).
"""
if not os.path.exists(file_path):
if self.logger:
await self.logger.error(f'DingTalk upload_image_media: file not found {file_path}')
return None
token = await self.get_legacy_access_token()
if not token:
return None
url = 'https://oapi.dingtalk.com/media/upload'
try:
with open(file_path, 'rb') as f:
file_bytes = f.read()
file_name = os.path.basename(file_path)
# Best-effort content-type guess; DingTalk accepts the major image
# mime types and otherwise infers from the bytes.
ext = os.path.splitext(file_name)[1].lower().lstrip('.')
mime = {'png': 'image/png', 'jpg': 'image/jpeg', 'jpeg': 'image/jpeg', 'gif': 'image/gif'}.get(
ext, 'application/octet-stream'
)
async with httpx.AsyncClient() as client:
response = await client.post(
url,
params={'access_token': token, 'type': 'image'},
files={'media': (file_name, file_bytes, mime)},
timeout=30.0,
)
data = response.json() if response.status_code == 200 else {}
if data.get('errcode') == 0 and data.get('media_id'):
_stdout_logger.info('DingTalk upload_image_media OK: media_id=%s', data['media_id'])
return data['media_id']
if self.logger:
await self.logger.error(
f'DingTalk upload_image_media failed: status={response.status_code} body={response.text[:300]}'
)
except Exception:
_stdout_logger.exception('DingTalk upload_image_media error')
if self.logger:
await self.logger.error(f'DingTalk upload_image_media error: {traceback.format_exc()}')
return None
async def start(self):
"""启动 WebSocket 连接,监听消息"""
self._stopped = False
@@ -525,7 +885,10 @@ class DingTalkClient:
while not self._stopped:
try:
connection = self.client.open_connection()
# open_connection performs blocking network I/O in the DingTalk SDK.
# Run it off the event loop so connection stalls do not block the
# LangBot HTTP server and other async tasks.
connection = await asyncio.to_thread(self.client.open_connection)
if not connection:
if self.logger:
@@ -0,0 +1,106 @@
"""STREAM-mode handler for DingTalk card action button clicks.
DingTalk delivers card-action callbacks over the same WebSocket stream used
for chatbot messages, under the topic `/v1.0/card/instances/callback`. This
module subclasses `dingtalk_stream.CallbackHandler` and forwards the parsed
payload to a coroutine the adapter registers, so the resume-paused-workflow
logic stays in the platform adapter where it belongs.
The `CardCallbackMessage` returned by `from_dict` exposes:
* `card_instance_id` (from `outTrackId`) the card whose button was clicked
* `user_id` the clicker's userId
* `content` parsed JSON; the click params live here. Where exactly inside
`content` they sit depends on the template binding. We probe
the common paths.
* `extension` parsed JSON; any extra data we set when delivering the card.
"""
from __future__ import annotations
from typing import Awaitable, Callable, Optional
import dingtalk_stream # type: ignore
from dingtalk_stream import AckMessage
from dingtalk_stream.card_callback import CardCallbackMessage
_PARAM_PATHS = (
('params',),
('cardPrivateData', 'params'),
('userPrivateData', 'params'),
('actionData', 'cardPrivateData', 'params'),
)
def _extract_params(content: dict) -> dict:
"""Return the action params dict regardless of where the template put it."""
for path in _PARAM_PATHS:
node = content
for key in path:
if not isinstance(node, dict):
node = None
break
node = node.get(key)
if node is None:
break
if isinstance(node, dict) and node:
return node
return {}
def _merge_params(*sources: dict) -> dict:
merged = {}
for source in sources:
if isinstance(source, dict):
merged.update(source)
return merged
class DingTalkCardActionHandler(dingtalk_stream.CallbackHandler):
def __init__(
self,
dingtalk_stream_client,
on_action: Optional[Callable[[dict], Awaitable[None]]] = None,
):
super().__init__()
self.dingtalk_client = dingtalk_stream_client
self.on_action = on_action
async def process(self, callback: dingtalk_stream.CallbackMessage):
try:
message = CardCallbackMessage.from_dict(callback.data)
content = message.content if isinstance(message.content, dict) else {}
# `CardCallbackMessage.from_dict` does not surface `actionId` (the
# top-level field that ButtonGroup's sendCardRequest event puts
# there). Pull it from the raw callback.data instead.
raw = callback.data if isinstance(callback.data, dict) else {}
params = _merge_params(_extract_params(content), _extract_params(raw))
action_id = raw.get('actionId') or ''
if not action_id:
# Some templates nest it under actionData / cardPrivateData.
action_data = raw.get('actionData') or {}
if isinstance(action_data, dict):
action_id = action_data.get('actionId') or action_id
if not action_id:
cpd = action_data.get('cardPrivateData') or {}
if isinstance(cpd, dict):
ids = cpd.get('actionIds')
if isinstance(ids, list) and ids:
action_id = str(ids[0])
payload = {
'out_track_id': message.card_instance_id,
'user_id': message.user_id,
'corp_id': message.corp_id,
'action_id': action_id,
'params': params,
'raw_content': message.content,
'extension': message.extension if isinstance(message.extension, dict) else {},
}
if self.on_action is not None:
await self.on_action(payload)
except Exception as e:
self.logger.error(f'DingTalkCardActionHandler.process error: {e}')
return AckMessage.STATUS_OK, 'OK'
+334 -9
View File
@@ -12,6 +12,142 @@ import traceback
from cryptography.hazmat.primitives.asymmetric import ed25519
QQ_SELECT_ACTION_PREFIX = '__langbot_select__:'
def get_select_field_options(form_data: dict) -> tuple[str, list[str]]:
"""Return the active select field name and its display/submission values."""
field_name = str(form_data.get('_current_input_field') or '').strip()
if not field_name:
return '', []
field = next(
(
item
for item in form_data.get('input_defs') or []
if str(item.get('output_variable_name') or '').strip() == field_name
),
None,
)
if not field or str(field.get('type') or '').strip().lower() != 'select':
return '', []
source = field.get('option_source') or {}
source_value = source.get('value') if isinstance(source, dict) else None
if isinstance(source_value, list):
return field_name, [str(item) for item in source_value]
if isinstance(source_value, str):
return field_name, [part.strip() for part in source_value.splitlines() if part.strip()]
options = field.get('options')
if not isinstance(options, list):
return field_name, []
values = []
for item in options:
if isinstance(item, dict):
values.append(str(item.get('label') or item.get('value') or ''))
else:
values.append(str(item))
return field_name, [value for value in values if value]
def build_keyboard_from_select_field(form_data: dict, *, buttons_per_row: int | None = None) -> dict:
"""Build callback buttons for the currently active Dify select field."""
_, options = get_select_field_options(form_data)
visible_options = options[:25]
if buttons_per_row is None:
# Keep small choices readable while fitting up to QQ's 5x5 limit.
buttons_per_row = min(5, max(2, (len(visible_options) + 4) // 5))
selection_actions = [
{
'id': f'{QQ_SELECT_ACTION_PREFIX}{idx}',
'title': option,
'button_style': 'secondary',
}
for idx, option in enumerate(visible_options)
]
return build_keyboard_from_form({'actions': selection_actions}, buttons_per_row=buttons_per_row)
def resolve_select_button_action(form_data: dict, action_id: str) -> tuple[str, str] | None:
"""Resolve a select-button callback to ``(field_name, option_value)``."""
if not action_id.startswith(QQ_SELECT_ACTION_PREFIX):
return None
try:
option_index = int(action_id[len(QQ_SELECT_ACTION_PREFIX) :])
except ValueError:
return None
field_name, options = get_select_field_options(form_data)
if not field_name or option_index < 0 or option_index >= len(options) or option_index >= 25:
return None
return field_name, options[option_index]
def build_keyboard_from_form(form_data: dict, *, buttons_per_row: int = 2) -> dict:
"""Build a QQ keyboard JSON payload from a Dify human-input form_data.
Each Dify ``action`` becomes a callback button (``action.type=1``)
whose ``data`` is set directly to the Dify ``action_id``. The
INTERACTION_CREATE event carries this back as
``data.resolved.button_data`` so the adapter can match the click to
the originating form.
Layout limits per spec: max 5 rows, max 5 buttons per row. We default
to 2 buttons per row for legibility; oversized button lists wrap
onto additional rows and overflow gets dropped (max 25 visible).
Args:
form_data: Dify ``{"actions": [{"id", "title", "button_style"}, ...]}``.
buttons_per_row: 1..5. Mobile UI looks best at 2.
Returns:
``{"content": {"rows": [{"buttons": [...]}]}}``.
"""
actions = list(form_data.get('actions') or [])[:25] # 5×5 hard cap
buttons_per_row = max(1, min(5, buttons_per_row))
def _button(idx: int, action: dict) -> dict:
action_id = str(action.get('id') or '')
label = str(action.get('title') or action_id or f'选项 {idx + 1}')
style_raw = (action.get('button_style') or '').lower()
# QQ: 0 灰色线框, 1 蓝色线框. Highlight the primary / first action.
if style_raw == 'primary' or (style_raw == '' and idx == 0):
style = 1
else:
style = 0
return {
'id': str(idx + 1),
'render_data': {
'label': label,
# Shown after the user clicks — gives local "已选择" feedback
# without a follow-up message. Style mimics DingTalk/Lark's
# in-card selection state.
'visited_label': f'{label}',
'style': style,
},
'action': {
'type': 1, # callback button
'permission': {'type': 2}, # everyone can click
'data': action_id,
'unsupport_tips': '当前客户端版本不支持此按钮,请升级 QQ',
},
}
rows = []
for row_start in range(0, len(actions), buttons_per_row):
row_actions = actions[row_start : row_start + buttons_per_row]
rows.append(
{
'buttons': [_button(row_start + j, a) for j, a in enumerate(row_actions)],
}
)
if len(rows) >= 5:
break
return {'content': {'rows': rows}}
class QQOfficialClient:
def __init__(self, secret: str, token: str, app_id: str, logger: None, unified_mode: bool = False):
self.unified_mode = unified_mode
@@ -30,6 +166,10 @@ class QQOfficialClient:
self.token = token
self.app_id = app_id
self._message_handlers = {}
# Single optional handler for INTERACTION_CREATE (button click). We
# don't multiplex like message handlers — only the adapter cares,
# and the click<->resume path needs a single source of truth.
self._interaction_handler: Optional[Callable[[Dict[str, Any], Optional[str]], Any]] = None
self.base_url = 'https://api.sgroup.qq.com'
self.access_token = ''
self.access_token_expiry_time = None
@@ -107,6 +247,23 @@ class QQOfficialClient:
return response, 200
if payload.get('op') == 0:
# INTERACTION_CREATE (button click) skips ``get_message`` —
# that helper only flattens message-event fields and would
# drop ``data.resolved.button_data`` / ``data.button_id``.
if payload.get('t') == 'INTERACTION_CREATE':
if self._interaction_handler:
try:
d = payload.get('d') or {}
# Top-level ``id`` is the ws/event id used as
# ``event_id`` for passive replies. ``d.id``
# is the interaction id used for ACK. Do not
# confuse the two — QQ rejects misuse with
# 40034025.
ws_event_id = payload.get('id')
await self._interaction_handler(d, ws_event_id)
except Exception:
await self.logger.error(f'Error in interaction handler: {traceback.format_exc()}')
return {'code': 0, 'message': 'success'}
message_data = await self.get_message(payload)
if message_data:
event = QQOfficialEvent.from_payload(message_data)
@@ -133,6 +290,21 @@ class QQOfficialClient:
return decorator
def on_interaction(self):
"""Register a single handler for INTERACTION_CREATE events.
The handler receives ``(data_dict, interaction_id)`` the raw
``d`` payload plus the top-level ``id`` field (the interaction
id, needed for the PUT /interactions/{id} ack and for reuse as
an ``event_id`` on the resumed reply within 30 minutes).
"""
def decorator(func: Callable[[Dict[str, Any], Optional[str]], Any]):
self._interaction_handler = func
return func
return decorator
async def _handle_message(self, event: QQOfficialEvent):
"""处理消息事件"""
msg_type = event.t
@@ -177,8 +349,20 @@ class QQOfficialClient:
content_type = attachment.get('content_type', '')
return content_type.startswith('image/')
async def send_private_text_msg(self, user_openid: str, content: str, msg_id: str):
"""发送私聊消息"""
async def send_private_text_msg(
self,
user_openid: str,
content: str,
msg_id: Optional[str] = None,
event_id: Optional[str] = None,
msg_seq: int = 1,
):
"""Send a c2c text message.
Either ``msg_id`` (inbound user msg, free passive reply) or
``event_id`` (e.g. INTERACTION_CREATE id, valid 30 min) is
required. Without either, the call costs the proactive-send quota.
"""
if not await self.check_access_token():
await self.get_access_token()
@@ -188,11 +372,15 @@ class QQOfficialClient:
'Authorization': f'QQBot {self.access_token}',
'Content-Type': 'application/json',
}
data = {
data: dict[str, Any] = {
'content': content,
'msg_type': 0,
'msg_id': msg_id,
'msg_seq': msg_seq,
}
if msg_id:
data['msg_id'] = msg_id
if event_id:
data['event_id'] = event_id
response = await client.post(url, headers=headers, json=data)
response_data = response.json()
if response.status_code == 200:
@@ -201,8 +389,19 @@ class QQOfficialClient:
await self.logger.error(f'Failed to send private message: {response_data}')
raise ValueError(response)
async def send_group_text_msg(self, group_openid: str, content: str, msg_id: str):
"""发送群聊消息"""
async def send_group_text_msg(
self,
group_openid: str,
content: str,
msg_id: Optional[str] = None,
event_id: Optional[str] = None,
msg_seq: int = 1,
):
"""Send a group text message.
Either ``msg_id`` or ``event_id`` is required (see
:meth:`send_private_text_msg` for the distinction).
"""
if not await self.check_access_token():
await self.get_access_token()
@@ -212,11 +411,15 @@ class QQOfficialClient:
'Authorization': f'QQBot {self.access_token}',
'Content-Type': 'application/json',
}
data = {
data: dict[str, Any] = {
'content': content,
'msg_type': 0,
'msg_id': msg_id,
'msg_seq': msg_seq,
}
if msg_id:
data['msg_id'] = msg_id
if event_id:
data['event_id'] = event_id
response = await client.post(url, headers=headers, json=data)
if response.status_code == 200:
return
@@ -485,6 +688,107 @@ class QQOfficialClient:
raise Exception(f'Failed to send stream message: HTTP {response.status_code} {response.text}')
return response.json()
async def send_markdown_keyboard(
self,
target_type: str,
target_id: str,
markdown_content: str,
keyboard: Optional[dict] = None,
msg_id: Optional[str] = None,
event_id: Optional[str] = None,
msg_seq: int = 1,
) -> dict:
"""Send a ``msg_type=2`` (markdown) message carrying a keyboard.
The keyboard ride-along is the only documented way to attach
buttons in QQ official; pure keyboard-only messages are not
accepted by the server (markdown content is required).
Args:
target_type: 'c2c' (single chat), 'group', 'channel' (text
channel uses POST /channels/{id}/messages instead of v2).
target_id: openid for c2c/group, channel_id for channel.
markdown_content: Plain markdown text shown above the buttons.
keyboard: ``{'content': {'rows': [{'buttons': [...]}]}}`` per
the official spec. Use :func:`build_keyboard_from_form`
to construct from Dify form_data.
msg_id: Inbound user message id; turns this into a passive
reply (preferred no monthly quota cost).
event_id: Use ``INTERACTION_CREATE`` event id from a prior
button click to keep within the 30-minute passive window
without an inbound msg_id.
msg_seq: De-dup counter when reusing msg_id.
"""
if not await self.check_access_token():
await self.get_access_token()
if target_type == 'c2c':
url = f'{self.base_url}/v2/users/{target_id}/messages'
elif target_type == 'group':
url = f'{self.base_url}/v2/groups/{target_id}/messages'
elif target_type == 'channel':
url = f'{self.base_url}/channels/{target_id}/messages'
else:
raise ValueError(f'Unsupported target_type for markdown+keyboard: {target_type}')
body: dict[str, Any] = {
'msg_type': 2,
'markdown': {'content': markdown_content},
'msg_seq': msg_seq,
}
if keyboard and keyboard.get('content', {}).get('rows'):
body['keyboard'] = keyboard
if msg_id:
body['msg_id'] = msg_id
if event_id:
body['event_id'] = event_id
async with httpx.AsyncClient(timeout=30) as client:
headers = {
'Authorization': f'QQBot {self.access_token}',
'Content-Type': 'application/json',
}
response = await client.post(url, headers=headers, json=body)
if response.status_code != 200:
await self.logger.error(
f'Failed to send markdown+keyboard: HTTP {response.status_code} {response.text}'
)
raise Exception(f'Failed to send markdown+keyboard: HTTP {response.status_code} {response.text}')
return response.json()
async def ack_interaction(self, interaction_id: str, code: int = 0) -> None:
"""Acknowledge a button-click INTERACTION_CREATE event.
QQ keeps the client in a loading spinner until this ack is
received. Should be called as soon as the click is parsed, before
any heavier downstream work (the actual workflow resume can run
async).
Args:
interaction_id: The ``id`` field from the INTERACTION_CREATE event.
code: 0=success, 1=fail, 2=rate-limited, 3=duplicate, 4=no
permission, 5=admin only. Default 0.
"""
if not interaction_id:
return
if not await self.check_access_token():
await self.get_access_token()
url = f'{self.base_url}/interactions/{interaction_id}'
async with httpx.AsyncClient(timeout=10) as client:
headers = {
'Authorization': f'QQBot {self.access_token}',
'Content-Type': 'application/json',
}
try:
response = await client.put(url, headers=headers, json={'code': code})
if response.status_code >= 400:
await self.logger.warning(
f'ack_interaction non-success: HTTP {response.status_code} {response.text}'
)
except Exception as e:
await self.logger.warning(f'ack_interaction error (non-fatal): {e}')
async def is_token_expired(self):
"""检查token是否过期"""
if self.access_token_expiry_time is None:
@@ -653,6 +957,12 @@ class QQOfficialClient:
d = payload.get('d', {})
s = payload.get('s')
t = payload.get('t')
# Top-level event id, distinct from `d.id`. Per QQ
# spec this is the only value accepted as ``event_id``
# in subsequent passive-reply send-message calls
# (``d.id`` for INTERACTION_CREATE is the interaction
# id, used solely for PUT /interactions/{id} ack).
ws_event_id = payload.get('id')
if not isinstance(d, dict):
d = {}
@@ -731,7 +1041,22 @@ class QQOfficialClient:
else:
await self.logger.debug(f'Received event: {t}, seq={s}')
if on_event:
# INTERACTION_CREATE bypasses the regular
# on_event dispatcher so the adapter sees the
# top-level ws_event_id (needed as event_id
# for the resumed reply) — same shape as the
# webhook handler.
if t == 'INTERACTION_CREATE':
if self._interaction_handler:
try:
result = self._interaction_handler(d, ws_event_id)
if asyncio.iscoroutine(result):
await result
except Exception:
await self.logger.error(
f'Error in interaction handler (ws): {traceback.format_exc()}'
)
elif on_event:
try:
result = on_event(t, d)
if asyncio.iscoroutine(result):
File diff suppressed because it is too large Load Diff
+286 -7
View File
@@ -20,7 +20,19 @@ from typing import Any, Callable, Optional
import aiohttp
from langbot.libs.wecom_ai_bot_api import wecombotevent
from langbot.libs.wecom_ai_bot_api.api import parse_wecom_bot_message, StreamSession
from langbot.libs.wecom_ai_bot_api.api import (
parse_wecom_bot_message,
StreamSession,
build_human_input_template_card_payload,
build_human_input_text_prompt,
build_button_interaction_update_card,
build_multiple_interaction_update_card,
extract_template_card_action,
extract_template_card_event_payload,
extract_template_card_selections,
extract_wecom_event_type,
parse_select_button_action,
)
from langbot.pkg.platform.logger import EventLogger
DEFAULT_WS_URL = 'wss://openws.work.weixin.qq.com'
@@ -43,6 +55,10 @@ def _generate_req_id(prefix: str) -> str:
return f'{prefix}_{ts}_{rand}'
def _frame_snippet(frame: dict, limit: int = 1000) -> str:
return json.dumps(frame, ensure_ascii=False, default=str)[:limit]
class WecomBotWsClient:
"""WeChat Work AI Bot WebSocket long connection client.
@@ -103,6 +119,22 @@ class WecomBotWsClient:
# msg_id -> feedback_id (for associating feedback with message)
self._msg_feedback_ids: dict[str, str] = {} # msg_id -> feedback_id
# Dify human-input pause state for ws mode. Keys are task_id (echoed
# back in template_card_event.TaskId so we can rebuild the session
# context on click).
# task_id -> {form_data, msg_id, user_id, chat_id, stream_id, req_id}
self._pending_forms_by_task: dict[str, dict] = {}
# Reverse: msg_id -> task_id (for cleanup when stream finishes).
self._task_id_by_msg: dict[str, str] = {}
# Optional card-action callback registered by the adapter.
# Signature mirrors the http-mode WecomBotClient:
# async def callback(session, action_id, task_id, raw_event) -> None
self._card_action_callback: Optional[Callable] = None
# Optional `source` block injected into every interactive
# template_card the client builds via `push_form_pause`. Set via
# `set_card_source` from the adapter after reading config.
self.card_source: Optional[dict] = None
# ── Public API ──────────────────────────────────────────────────
async def connect(self):
@@ -236,6 +268,132 @@ class WecomBotWsClient:
}
return await self._send_reply(req_id, body)
async def reply_template_card(self, req_id: str, card_payload: dict[str, Any]) -> Optional[dict]:
"""Send a template_card (button_interaction etc.) reply.
Args:
req_id: The req_id from the original message frame.
card_payload: Body produced by ``build_button_interaction_payload``;
must contain ``msgtype`` and ``template_card`` keys.
Returns:
ACK frame dict, or None on failure.
"""
return await self._send_reply(req_id, card_payload)
async def update_template_card(
self,
req_id: str,
template_card: dict[str, Any],
) -> Optional[dict]:
"""Update an existing template_card via WebSocket.
Uses the ``aibot_respond_update_msg`` command. Must be called
within 5 seconds of receiving the ``template_card_event`` callback,
using the **same req_id** from that callback.
The ``template_card`` dict should contain ``card_type`` and the
new content fields (e.g. ``main_title``, ``button_list`` with
disabled buttons and ``replace_text``).
Returns:
ACK frame dict, or None on failure.
"""
body: dict[str, Any] = {
'response_type': 'update_template_card',
'template_card': template_card,
}
return await self._send_reply(req_id, body, cmd=CMD_RESPOND_UPDATE)
def set_card_action_callback(self, callback: Callable) -> None:
"""Register the button-click handler.
``async def callback(session, action_id, task_id, raw_event) -> None``
same signature as the http-mode WecomBotClient version so the
adapter can hand both off to the same coroutine.
"""
self._card_action_callback = callback
def set_card_source(self, source: Optional[dict]) -> None:
"""Set the `source` block injected into every interactive
template_card pushed via `push_form_pause`. Pass None to clear."""
self.card_source = source
async def push_form_pause(
self, msg_id: str, form_data: dict, task_id: Optional[str] = None
) -> tuple[bool, Optional[str], Optional[str]]:
"""Attach a Dify human-input pause to the active stream and send
the button_interaction card immediately.
ws mode has no notion of polled "followup" responses each reply
is a one-shot frame send. So unlike the http path (which defers
card delivery to the next followup), here we just craft the card
and reply with it on the original req_id. The corresponding stream
session is then torn down so subsequent chunks don't re-send.
Returns:
``(ok, stream_id, task_id)``. ``ok=False`` if no active stream
for this msg_id (e.g. message arrived in non-stream mode).
"""
key = self._stream_ids.get(msg_id)
if not key:
return False, None, None
req_id, stream_id = key.split('|', 1)
if not task_id:
task_id = f'dify-{secrets.token_hex(12)}'
session_info = self._stream_sessions.get(msg_id) or {}
text_prompt = build_human_input_text_prompt(form_data)
if text_prompt:
try:
ack = await self.reply_text(req_id, text_prompt)
if ack is None:
return False, stream_id, None
except Exception:
await self.logger.error(f'Failed to send human-input text prompt: {traceback.format_exc()}')
return False, stream_id, None
self._stream_ids.pop(msg_id, None)
self._stream_last_content.pop(msg_id, None)
self._stream_sessions.pop(msg_id, None)
return True, stream_id, None
self._pending_forms_by_task[task_id] = {
'form_data': form_data,
'msg_id': msg_id,
'user_id': session_info.get('user_id', ''),
'chat_id': session_info.get('chat_id', ''),
'stream_id': stream_id,
'req_id': req_id,
}
self._task_id_by_msg[msg_id] = task_id
card_payload = build_human_input_template_card_payload(
form_data,
task_id,
source=self.card_source,
select_as_buttons=True,
)
try:
await self.reply_template_card(req_id, card_payload)
except Exception:
await self.logger.error(f'Failed to send button_interaction card: {traceback.format_exc()}')
# Roll back the bookkeeping so the next attempt isn't blocked.
self._pending_forms_by_task.pop(task_id, None)
self._task_id_by_msg.pop(msg_id, None)
return False, stream_id, None
# Tear down the stream — WeCom expects either stream chunks OR a
# template_card, not both on the same req_id. Subsequent
# push_stream_chunk calls for this msg_id become no-ops.
self._stream_ids.pop(msg_id, None)
self._stream_last_content.pop(msg_id, None)
# Keep _stream_sessions so the button callback can still resolve
# user/chat context; it gets cleaned up when the click fires.
return True, stream_id, task_id
async def send_message(self, chat_id: str, content: str, msgtype: str = 'markdown') -> Optional[dict]:
"""Proactively send a message to a specified chat.
@@ -258,6 +416,23 @@ class WecomBotWsClient:
body['text'] = {'content': content}
return await self._send_reply(req_id, body, cmd=CMD_SEND_MSG)
async def send_template_card(self, chat_id: str, card_payload: dict[str, Any]) -> Optional[dict]:
"""Proactively push a template_card to a chat.
Used for the resumed-workflow path (button click new query):
synthetic events have no inbound req_id to reply against, so we
fall back to proactive ``aibot_send_msg`` instead of reply mode.
Args:
chat_id: userid (single chat) or chatid (group chat).
card_payload: ``{"msgtype": "template_card", "template_card": {...}}``
as produced by :func:`build_button_interaction_payload`.
"""
req_id = _generate_req_id(CMD_SEND_MSG)
body = dict(card_payload)
body['chatid'] = chat_id
return await self._send_reply(req_id, body, cmd=CMD_SEND_MSG)
async def push_stream_chunk(self, msg_id: str, content: str, is_final: bool = False) -> bool:
"""Push a streaming chunk for a given message ID.
@@ -276,10 +451,31 @@ class WecomBotWsClient:
return False
req_id, stream_id = key.split('|', 1)
try:
previous_content = self._stream_last_content.get(msg_id, '')
if previous_content and content.startswith(previous_content):
next_content = content
elif previous_content and not content:
next_content = previous_content
else:
next_content = previous_content + content if previous_content else content
# Skip sending if content hasn't changed (e.g. during tool call argument streaming)
if not is_final and content == self._stream_last_content.get(msg_id):
if not is_final and next_content == previous_content:
return True
# Skip empty/whitespace-only snapshots — the runner injects a
# zero-width space ('') as a pass-through when workflow_paused
# fires without any preceding LLM output. WeCom renders that
# as an empty bubble that sits before the form card; skip it.
# NOTE: Python str.strip() does NOT strip , so we use
# a regex that treats any character with Unicode category Zs
# (separator space) or Cf (format char like ZWS) as blank.
if not is_final:
import re as _re
if not _re.sub(r'[\s]', '', next_content):
return True
# Generate feedback_id for final chunk
feedback_id = ''
if is_final:
@@ -290,8 +486,10 @@ class WecomBotWsClient:
if session_info:
self._feedback_sessions[feedback_id] = session_info
await self.reply_stream(req_id, stream_id, content, finish=is_final, feedback_id=feedback_id)
self._stream_last_content[msg_id] = content
# WeCom replaces the displayed stream content on each refresh, so
# every frame must contain the complete snapshot, not only a delta.
await self.reply_stream(req_id, stream_id, next_content, finish=is_final, feedback_id=feedback_id)
self._stream_last_content[msg_id] = next_content
if is_final:
self._stream_ids.pop(msg_id, None)
self._stream_last_content.pop(msg_id, None)
@@ -465,7 +663,7 @@ class WecomBotWsClient:
return
# Unknown frame
await self.logger.warning(f'Unknown frame: {json.dumps(frame, ensure_ascii=False)[:200]}')
await self.logger.warning(f'Unknown frame: {_frame_snippet(frame)}')
async def _handle_message_callback(self, frame: dict):
"""Handle an incoming message callback frame."""
@@ -473,6 +671,13 @@ class WecomBotWsClient:
body = frame.get('body', {})
req_id = frame.get('headers', {}).get('req_id', '')
event_type = extract_wecom_event_type(body)
if event_type == 'template_card_event':
await self._handle_template_card_event_frame(frame, body)
return
if event_type:
await self.logger.debug(f'Received msg_callback event_type={event_type}: {_frame_snippet(frame)}')
# Parse message using shared logic
message_data = await parse_wecom_bot_message(body, self.encoding_aes_key, self.logger)
if not message_data:
@@ -506,8 +711,12 @@ class WecomBotWsClient:
body = frame.get('body', {})
req_id = frame.get('headers', {}).get('req_id', '')
event_info = body.get('event', {})
event_type = event_info.get('eventtype', '')
event_info = body.get('event', {}) if isinstance(body.get('event'), dict) else body
event_type = extract_wecom_event_type(body)
if not event_type:
await self.logger.warning(f'Received event_callback without event_type: {_frame_snippet(frame)}')
else:
await self.logger.debug(f'Received event_callback event_type={event_type}')
message_data = {
'msgtype': 'event',
@@ -568,6 +777,10 @@ class WecomBotWsClient:
await self.logger.error(f'Error in feedback handler: {traceback.format_exc()}')
return
if event_type == 'template_card_event':
await self._handle_template_card_event_frame(frame, body)
return
event = wecombotevent.WecomBotEvent(message_data)
if event_type in self._message_handlers:
@@ -581,6 +794,72 @@ class WecomBotWsClient:
except Exception:
await self.logger.error(f'Error in event callback: {traceback.format_exc()}')
async def _handle_template_card_event_frame(self, frame: dict, body: dict):
"""Handle template_card_event frames from event_callback or msg_callback."""
tce = extract_template_card_event_payload(body)
task_id, event_key, card_type = extract_template_card_action(tce)
await self.logger.info(
f'Received template_card_event (ws): task_id={task_id} event_key={event_key!r} card_type={card_type}'
)
pending = self._pending_forms_by_task.get(task_id)
if pending is None:
await self.logger.warning(f'No pending_form found for task_id={task_id} (ws); card event ignored')
return
req_id_for_update = frame.get('headers', {}).get('req_id', '')
form_data = pending.get('form_data', {}) or {}
selections = extract_template_card_selections(tce, form_data)
if not selections:
selections = parse_select_button_action(event_key, form_data)
if card_type == 'multiple_interaction' and not selections:
await self.logger.warning(
f'multiple_interaction callback has no parseable selections (ws): raw={str(tce)[:1000]}'
)
self._drop_pending_form_task(task_id, pending)
return
update_card = build_button_interaction_update_card(
form_data,
task_id,
event_key,
source=self.card_source,
)
if card_type == 'multiple_interaction' or selections:
update_card = build_multiple_interaction_update_card(
form_data,
task_id,
selections,
source=self.card_source,
)
try:
await self.update_template_card(req_id_for_update, update_card)
except Exception:
await self.logger.warning(f'Failed to update template card (ws): {traceback.format_exc()}')
if self._card_action_callback is not None:
try:
session = StreamSession(
stream_id=pending.get('stream_id', ''),
msg_id=pending.get('msg_id', ''),
chat_id=pending.get('chat_id') or None,
user_id=pending.get('user_id') or None,
)
session.pending_form = pending.get('form_data')
session.pending_form_task_id = task_id
await self._card_action_callback(session, event_key, task_id, body)
except Exception:
await self.logger.error(f'card action callback raised (ws): {traceback.format_exc()}')
self._drop_pending_form_task(task_id, pending)
def _drop_pending_form_task(self, task_id: str, pending: dict) -> None:
self._pending_forms_by_task.pop(task_id, None)
msg_id = pending.get('msg_id', '')
if msg_id:
self._task_id_by_msg.pop(msg_id, None)
self._stream_sessions.pop(msg_id, None)
async def _dispatch_event(self, event: wecombotevent.WecomBotEvent):
"""Dispatch a message event to registered handlers with deduplication."""
try:
@@ -138,6 +138,39 @@ class MonitoringRouterGroup(group.RouterGroup):
}
)
@self.route('/tool-calls', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
async def get_tool_calls() -> str:
"""Get tool call records"""
bot_ids = quart.request.args.getlist('botId')
pipeline_ids = quart.request.args.getlist('pipelineId')
session_ids = quart.request.args.getlist('sessionId')
start_time_str = quart.request.args.get('startTime')
end_time_str = quart.request.args.get('endTime')
limit = int(quart.request.args.get('limit', 100))
offset = int(quart.request.args.get('offset', 0))
start_time = parse_iso_datetime(start_time_str)
end_time = parse_iso_datetime(end_time_str)
tool_calls, total = await self.ap.monitoring_service.get_tool_calls(
bot_ids=bot_ids if bot_ids else None,
pipeline_ids=pipeline_ids if pipeline_ids else None,
session_ids=session_ids if session_ids else None,
start_time=start_time,
end_time=end_time,
limit=limit,
offset=offset,
)
return self.success(
data={
'tool_calls': tool_calls,
'total': total,
'limit': limit,
'offset': offset,
}
)
@self.route('/embedding-calls', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
async def get_embedding_calls() -> str:
"""Get embedding call records"""
@@ -284,6 +317,16 @@ class MonitoringRouterGroup(group.RouterGroup):
offset=0,
)
# Get tool calls
tool_calls, tool_calls_total = await self.ap.monitoring_service.get_tool_calls(
bot_ids=bot_ids if bot_ids else None,
pipeline_ids=pipeline_ids if pipeline_ids else None,
start_time=start_time,
end_time=end_time,
limit=limit,
offset=0,
)
# Get sessions
sessions, sessions_total = await self.ap.monitoring_service.get_sessions(
bot_ids=bot_ids if bot_ids else None,
@@ -318,12 +361,14 @@ class MonitoringRouterGroup(group.RouterGroup):
'overview': overview,
'messages': messages,
'llmCalls': llm_calls,
'toolCalls': tool_calls,
'embeddingCalls': embedding_calls,
'sessions': sessions,
'errors': errors,
'totalCount': {
'messages': messages_total,
'llmCalls': llm_calls_total,
'toolCalls': tool_calls_total,
'embeddingCalls': embedding_calls_total,
'sessions': sessions_total,
'errors': errors_total,
@@ -21,7 +21,7 @@ import quart
from ... import group
from ......utils import paths
from ......platform.sources.websocket_manager import ws_connection_manager
from ......platform.sources.websocket_manager import is_valid_session_id, ws_connection_manager
logger = logging.getLogger(__name__)
@@ -203,11 +203,15 @@ class EmbedRouterGroup(group.RouterGroup):
if session_type not in ['person', 'group']:
return self.http_status(400, -1, 'session_type must be person or group')
session_id = quart.request.args.get('session_id', '')
if not is_valid_session_id(session_id):
return self.http_status(400, -1, 'Valid session_id is required')
websocket_adapter = self.ap.platform_mgr.websocket_proxy_bot.adapter
if not websocket_adapter:
return self.http_status(404, -1, 'WebSocket adapter not found')
messages = websocket_adapter.get_websocket_messages(pipeline_uuid, session_type)
messages = websocket_adapter.get_websocket_messages(pipeline_uuid, session_type, session_id)
return self.success(data={'messages': messages})
except Exception as e:
@@ -227,11 +231,15 @@ class EmbedRouterGroup(group.RouterGroup):
if session_type not in ['person', 'group']:
return self.http_status(400, -1, 'session_type must be person or group')
session_id = quart.request.args.get('session_id', '')
if not is_valid_session_id(session_id):
return self.http_status(400, -1, 'Valid session_id is required')
websocket_adapter = self.ap.platform_mgr.websocket_proxy_bot.adapter
if not websocket_adapter:
return self.http_status(404, -1, 'WebSocket adapter not found')
websocket_adapter.reset_session(pipeline_uuid, session_type)
websocket_adapter.reset_session(pipeline_uuid, session_type, session_id)
return self.success(data={'message': 'Session reset successfully'})
except Exception as e:
@@ -294,6 +302,11 @@ class EmbedRouterGroup(group.RouterGroup):
)
return
session_id = quart.websocket.args.get('session_id', '')
if not is_valid_session_id(session_id):
await quart.websocket.send(json.dumps({'type': 'error', 'message': 'Valid session_id is required'}))
return
websocket_adapter = self.ap.platform_mgr.websocket_proxy_bot.adapter
if not websocket_adapter:
await quart.websocket.send(json.dumps({'type': 'error', 'message': 'WebSocket adapter not found'}))
@@ -304,6 +317,7 @@ class EmbedRouterGroup(group.RouterGroup):
websocket=quart.websocket._get_current_object(),
pipeline_uuid=pipeline_uuid,
session_type=session_type,
session_id=session_id,
metadata={'user_agent': quart.websocket.headers.get('User-Agent', '')},
)
@@ -86,6 +86,10 @@ class PipelinesRouterGroup(group.RouterGroup):
'available_plugins': plugins,
'bound_mcp_servers': extensions_prefs.get('mcp_servers', []),
'available_mcp_servers': mcp_servers,
'bound_mcp_resources': extensions_prefs.get('mcp_resources', []),
'mcp_resource_agent_read_enabled': extensions_prefs.get(
'mcp_resource_agent_read_enabled', True
),
'bound_skills': extensions_prefs.get('skills', []),
'available_skills': available_skills,
}
@@ -99,6 +103,8 @@ class PipelinesRouterGroup(group.RouterGroup):
bound_plugins = json_data.get('bound_plugins', [])
bound_mcp_servers = json_data.get('bound_mcp_servers', [])
bound_skills = json_data.get('bound_skills', [])
bound_mcp_resources = json_data.get('bound_mcp_resources')
mcp_resource_agent_read_enabled = json_data.get('mcp_resource_agent_read_enabled')
await self.ap.pipeline_service.update_pipeline_extensions(
pipeline_uuid,
@@ -108,6 +114,8 @@ class PipelinesRouterGroup(group.RouterGroup):
enable_all_mcp_servers,
bound_skills=bound_skills,
enable_all_skills=enable_all_skills,
bound_mcp_resources=bound_mcp_resources,
mcp_resource_agent_read_enabled=mcp_resource_agent_read_enabled,
)
return self.success()
@@ -5,6 +5,29 @@ from ... import group
from langbot.pkg.utils import importutil
def _decrypt_qqofficial_secret(encrypted_b64: str, key: bytes) -> str:
"""Decrypt the AppSecret returned by the QQ Official QR binding endpoint.
The base64 payload is laid out as `nonce (12 B) | ciphertext | tag (16 B)`.
`key` is the 32-byte AES-256 key locally generated when the bind task
was created and submitted as `key` to `q.qq.com/lite/create_bind_task`.
"""
import base64
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
try:
raw = base64.b64decode(encrypted_b64)
except Exception as exc:
raise ValueError('Malformed encrypted credential') from exc
if len(key) != 32 or len(raw) <= 28:
raise ValueError('Invalid encrypted credential layout')
nonce, ciphertext, tag = raw[:12], raw[12:-16], raw[-16:]
try:
return AESGCM(key).decrypt(nonce, ciphertext + tag, None).decode('utf-8')
except Exception as exc:
raise ValueError('Failed to decrypt credential') from exc
@group.group_class('adapters', '/api/v1/platform/adapters')
class AdaptersRouterGroup(group.RouterGroup):
async def initialize(self) -> None:
@@ -37,6 +60,15 @@ class AdaptersRouterGroup(group.RouterGroup):
importutil.read_resource_file_bytes(icon_path), mimetype=mimetypes.guess_type(icon_path)[0]
)
@self.route('/dingtalk/human-input-card-template', methods=['GET'], auth_type=group.AuthType.NONE)
async def _() -> quart.Response:
filename = 'dingtalk_human_input_card.json'
response = quart.Response(
importutil.read_resource_file_bytes(f'templates/{filename}'), mimetype='application/json'
)
response.headers['Content-Disposition'] = f'attachment; filename={filename}'
return response
# In-memory session store for active registrations
_create_app_sessions: dict = {}
_SESSION_TTL = 900 # 15 minutes
@@ -650,3 +682,220 @@ class AdaptersRouterGroup(group.RouterGroup):
if session and session.get('task') and not session['task'].done():
session['task'].cancel()
return self.success(data={})
# -----------------------------------------------------------------------
# QQ Official QR Binding
# -----------------------------------------------------------------------
_qqofficial_sessions: dict = {}
_QQOFFICIAL_SESSION_TTL = 300 # 5 minutes (QQ bind QR validity window)
def _cleanup_expired_qqofficial_sessions():
import time
now = time.time()
expired = [
sid for sid, s in _qqofficial_sessions.items() if now - s.get('created_at', 0) > _QQOFFICIAL_SESSION_TTL
]
for sid in expired:
session = _qqofficial_sessions.pop(sid, None)
if session and session.get('task') and not session['task'].done():
session['task'].cancel()
@self.route('/qqofficial/bind', methods=['POST'])
async def _() -> str:
"""Start QQ Official QR binding. Returns session_id + QR URL.
Flow: generate a local AES-256 key, register it with
`q.qq.com/lite/create_bind_task`, then poll
`q.qq.com/lite/poll_bind_result` until the user authorizes the
bind inside the QQ Bot Assistant on mobile QQ. The encrypted
AppSecret returned by the poll endpoint is decrypted with the
same key. The key never leaves this process.
"""
import uuid
import time
import secrets
import base64
import aiohttp
QQ_BIND_BASE = 'https://q.qq.com'
_cleanup_expired_qqofficial_sessions()
bind_key_bytes = secrets.token_bytes(32)
bind_key = base64.b64encode(bind_key_bytes).decode('ascii')
session_id = str(uuid.uuid4())
session = {
'status': 'pending',
'qr_url': None,
'expire_at': None,
'appid': None,
'secret': None,
'user_openid': None,
'error': None,
'created_at': time.time(),
'task_id': None,
'bind_key_bytes': bind_key_bytes,
'interval': 2,
}
_qqofficial_sessions[session_id] = session
async def run_qr_binding():
try:
timeout = aiohttp.ClientTimeout(total=10)
async with aiohttp.ClientSession(timeout=timeout) as http:
# Step 1: create_bind_task — register our AES key, get task_id
async with http.post(
f'{QQ_BIND_BASE}/lite/create_bind_task',
json={'key': bind_key},
headers={'Accept': 'application/json'},
) as resp:
try:
data = await resp.json(content_type=None)
except (aiohttp.ContentTypeError, ValueError):
session['status'] = 'error'
session['error'] = 'Invalid response from QQ bind service'
return
if int(data.get('retcode', -1)) != 0:
session['status'] = 'error'
session['error'] = (
data.get('msg') or data.get('message') or 'Failed to create bind task'
)
return
task_id = str((data.get('data') or {}).get('task_id') or '').strip()
if not task_id:
session['status'] = 'error'
session['error'] = 'Missing task_id in QQ response'
return
# The QR encodes a URL that mobile QQ opens inside the QQ Bot Assistant.
# `source=langbot` is a courtesy attribution parameter so Tencent
# can see LangBot adoption metrics, matching the convention used by
# other third-party integrations (e.g. hermes-agent uses `source=hermes`).
qr_url = f'{QQ_BIND_BASE}/qqbot/openclaw/connect.html?task_id={task_id}&_wv=2&source=langbot'
session['task_id'] = task_id
session['qr_url'] = qr_url
session['expire_at'] = time.time() + _QQOFFICIAL_SESSION_TTL
session['status'] = 'waiting'
# Step 2: poll_bind_result until completed (status=2) or expired (3).
deadline = time.time() + _QQOFFICIAL_SESSION_TTL
while time.time() < deadline:
await asyncio.sleep(session['interval'])
async with http.post(
f'{QQ_BIND_BASE}/lite/poll_bind_result',
json={'task_id': task_id},
headers={'Accept': 'application/json'},
) as poll_resp:
try:
poll_data = await poll_resp.json(content_type=None)
except (aiohttp.ContentTypeError, ValueError):
continue
if int(poll_data.get('retcode', -1)) != 0:
session['status'] = 'error'
session['error'] = poll_data.get('msg') or poll_data.get('message') or 'Poll failed'
return
payload = poll_data.get('data') or {}
try:
raw_status = int(payload.get('status', 0))
except (TypeError, ValueError):
raw_status = 0
if raw_status == 2:
appid = str(payload.get('bot_appid') or '').strip()
encrypted = str(payload.get('bot_encrypt_secret') or '').strip()
if not appid or not encrypted:
session['status'] = 'error'
session['error'] = 'Incomplete credential payload'
return
try:
session['secret'] = _decrypt_qqofficial_secret(
encrypted,
bind_key_bytes,
)
except ValueError as exc:
session['status'] = 'error'
session['error'] = str(exc)
return
session['appid'] = appid
# The scanner's OpenID is returned alongside the credentials —
# surfaced to the dashboard for audit / "bound by" display.
session['user_openid'] = str(payload.get('user_openid') or '').strip() or None
session['status'] = 'success'
return
if raw_status == 3:
session['status'] = 'expired'
session['error'] = 'QR code expired'
return
# status 0 / 1: still pending, continue polling
session['status'] = 'expired'
session['error'] = 'QR code expired'
except asyncio.CancelledError:
return
except Exception as e:
session['status'] = 'error'
session['error'] = str(e)
task = asyncio.create_task(run_qr_binding())
session['task'] = task
# Wait up to 10s for the QR URL to be ready before responding.
for _ in range(20):
if session['qr_url'] or session['error']:
break
await asyncio.sleep(0.5)
if session['error']:
task.cancel()
return self.http_status(502, -1, session['error'])
if not session['qr_url']:
task.cancel()
session['status'] = 'error'
session['error'] = 'Timeout waiting for QR code'
return self.http_status(504, -1, 'Timeout waiting for QR code')
return self.success(
data={
'session_id': session_id,
'qr_url': session['qr_url'],
'expire_at': session['expire_at'],
}
)
@self.route('/qqofficial/bind/status/<session_id>', methods=['GET'])
async def _(session_id: str) -> str:
"""Poll QQ Official QR binding status."""
_cleanup_expired_qqofficial_sessions()
session = _qqofficial_sessions.get(session_id)
if not session:
return self.http_status(404, -1, 'Session not found')
data = {'status': session['status']}
if session['status'] == 'success':
data['appid'] = session['appid']
data['secret'] = session['secret']
if session.get('user_openid'):
data['user_openid'] = session['user_openid']
_qqofficial_sessions.pop(session_id, None)
elif session['status'] in ('error', 'expired'):
data['error'] = session['error']
_qqofficial_sessions.pop(session_id, None)
return self.success(data=data)
@self.route('/qqofficial/bind/<session_id>', methods=['DELETE'])
async def _(session_id: str) -> str:
"""Cancel and clean up a QQ Official QR binding session."""
session = _qqofficial_sessions.pop(session_id, None)
if session and session.get('task') and not session['task'].done():
session['task'].cancel()
return self.success(data={})
@@ -18,7 +18,6 @@ class BotsRouterGroup(group.RouterGroup):
@self.route('/<bot_uuid>', methods=['GET', 'PUT', 'DELETE'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
async def _(bot_uuid: str) -> str:
if quart.request.method == 'GET':
# 返回运行时信息,包括webhook地址等
bot = await self.ap.bot_service.get_runtime_bot_info(bot_uuid)
if bot is None:
return self.http_status(404, -1, 'bot not found')
@@ -37,30 +36,21 @@ class BotsRouterGroup(group.RouterGroup):
from_index = json_data.get('from_index', -1)
max_count = json_data.get('max_count', 10)
logs, total_count = await self.ap.bot_service.list_event_logs(bot_uuid, from_index, max_count)
return self.success(
data={
'logs': logs,
'total_count': total_count,
}
)
return self.success(data={'logs': logs, 'total_count': total_count})
@self.route('/<bot_uuid>/send_message', methods=['POST'], auth_type=group.AuthType.API_KEY)
async def _(bot_uuid: str) -> str:
"""Send message to a specific target via bot"""
json_data = await quart.request.json
target_type = json_data.get('target_type')
target_id = json_data.get('target_id')
message_chain_data = json_data.get('message_chain')
# Validate required fields
if not target_type:
return self.http_status(400, -1, 'target_type is required')
if not target_id:
return self.http_status(400, -1, 'target_id is required')
if not message_chain_data:
return self.http_status(400, -1, 'message_chain is required')
# Validate target_type
if target_type not in ['person', 'group']:
return self.http_status(400, -1, 'target_type must be either "person" or "group"')
@@ -72,3 +62,29 @@ class BotsRouterGroup(group.RouterGroup):
traceback.print_exc()
return self.http_status(500, -1, f'Failed to send message: {str(e)}')
# ============ Bot Admins ============
@self.route('/<bot_uuid>/admins', methods=['GET', 'POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
async def _(bot_uuid: str) -> str:
if quart.request.method == 'GET':
admins = await self.ap.bot_service.get_bot_admins(bot_uuid)
return self.success(data={'admins': admins})
elif quart.request.method == 'POST':
json_data = await quart.request.json
launcher_type = json_data.get('launcher_type', '').strip()
launcher_id = str(json_data.get('launcher_id', '')).strip()
if not launcher_type or not launcher_id:
return self.http_status(400, -1, 'launcher_type and launcher_id are required')
try:
admin_id = await self.ap.bot_service.add_bot_admin(bot_uuid, launcher_type, launcher_id)
return self.success(data={'id': admin_id})
except Exception as e:
return self.http_status(409, -1, str(e))
@self.route(
'/<bot_uuid>/admins/<int:admin_id>', methods=['DELETE'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY
)
async def _(bot_uuid: str, admin_id: int) -> str:
await self.ap.bot_service.delete_bot_admin(bot_uuid, admin_id)
return self.success()
@@ -2,6 +2,7 @@ from __future__ import annotations
import quart
import traceback
from urllib.parse import unquote
from ... import group
@@ -28,11 +29,11 @@ class MCPRouterGroup(group.RouterGroup):
traceback.print_exc()
return self.http_status(500, -1, f'Failed to create MCP server: {str(e)}')
@self.route('/servers/<server_name>', methods=['GET', 'PUT', 'DELETE'], auth_type=group.AuthType.USER_TOKEN)
@self.route(
'/servers/<path:server_name>', methods=['GET', 'PUT', 'DELETE'], auth_type=group.AuthType.USER_TOKEN
)
async def _(server_name: str) -> str:
"""获取、更新或删除MCP服务器配置"""
from urllib.parse import unquote
server_name = unquote(server_name)
server_data = await self.ap.mcp_service.get_mcp_server_by_name(server_name)
@@ -57,12 +58,72 @@ class MCPRouterGroup(group.RouterGroup):
except Exception as e:
return self.http_status(500, -1, f'Failed to delete MCP server: {str(e)}')
@self.route('/servers/<server_name>/test', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
@self.route('/servers/<path:server_name>/test', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
async def _(server_name: str) -> str:
"""测试MCP服务器连接"""
from urllib.parse import unquote
server_name = unquote(server_name)
server_data = await quart.request.json
task_id = await self.ap.mcp_service.test_mcp_server(server_name=server_name, server_data=server_data)
return self.success(data={'task_id': task_id})
@self.route('/servers/<path:server_name>/resources', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
async def _(server_name: str) -> str:
"""Get resources from an MCP server"""
server_name = unquote(server_name)
try:
resources = await self.ap.mcp_service.get_mcp_server_resources(server_name)
templates = await self.ap.mcp_service.get_mcp_server_resource_templates(server_name)
runtime_info = await self.ap.mcp_service.get_runtime_info(server_name)
return self.success(
data={
'resources': resources,
'resource_templates': templates,
'resource_capabilities': (runtime_info or {}).get('resource_capabilities', {}),
}
)
except Exception as e:
return self.http_status(500, -1, f'Failed to get resources: {str(e)}')
@self.route(
'/servers/<path:server_name>/resource-templates', methods=['GET'], auth_type=group.AuthType.USER_TOKEN
)
async def _(server_name: str) -> str:
"""Get resource templates from an MCP server"""
server_name = unquote(server_name)
try:
templates = await self.ap.mcp_service.get_mcp_server_resource_templates(server_name)
return self.success(data={'resource_templates': templates})
except Exception as e:
return self.http_status(500, -1, f'Failed to get resource templates: {str(e)}')
@self.route('/servers/<path:server_name>/logs', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
async def _(server_name: str) -> str:
"""Get logs from an MCP server"""
server_name = unquote(server_name)
try:
limit = int(quart.request.args.get('limit', 200))
except (TypeError, ValueError):
limit = 200
limit = min(limit, 500)
level = quart.request.args.get('level') or None
logs = await self.ap.mcp_service.get_mcp_server_logs(server_name, limit=limit, level=level)
return self.success(data={'logs': logs})
@self.route('/servers/<path:server_name>/resources/read', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
async def _(server_name: str) -> str:
"""Read a resource from an MCP server"""
server_name = unquote(server_name)
data = await quart.request.json
uri = data.get('uri')
if not uri:
return self.http_status(400, -1, 'URI is required')
try:
envelope = await self.ap.mcp_service.read_mcp_server_resource_envelope(
server_name,
uri,
max_bytes=data.get('max_bytes'),
include_blob=bool(data.get('include_blob', False)),
)
return self.success(data=envelope)
except Exception as e:
return self.http_status(500, -1, f'Failed to read resource: {str(e)}')
@@ -1,5 +1,7 @@
from __future__ import annotations
import quart
from ... import group
@@ -9,25 +11,41 @@ class ToolsRouterGroup(group.RouterGroup):
@self.route('', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
async def _() -> str:
"""获取所有可用工具列表"""
tools = await self.ap.tool_mgr.get_all_tools()
pipeline_uuid = quart.request.args.get('pipeline_uuid') or quart.request.args.get('pipeline_id')
bound_plugins: list[str] | None = None
bound_mcp_servers: list[str] | None = None
tool_list = []
for tool in tools:
tool_list.append(
{
'name': tool.name,
'description': tool.description,
'human_desc': tool.human_desc,
'parameters': tool.parameters,
}
)
if pipeline_uuid:
pipeline = await self.ap.pipeline_service.get_pipeline(pipeline_uuid)
if pipeline is None:
return self.http_status(404, -1, 'pipeline not found')
return self.success(data={'tools': tool_list})
extensions_prefs = pipeline.get('extensions_preferences', {}) or {}
if not extensions_prefs.get('enable_all_plugins', True):
bound_plugins = [
f'{plugin.get("author", "")}/{plugin.get("name", "")}'
for plugin in extensions_prefs.get('plugins', [])
if isinstance(plugin, dict) and plugin.get('name')
]
if not extensions_prefs.get('enable_all_mcp_servers', True):
bound_mcp_servers = [
server for server in (extensions_prefs.get('mcp_servers', []) or []) if isinstance(server, str)
]
return self.success(
data={
'tools': await self.ap.tool_mgr.get_tool_catalog(
bound_plugins,
bound_mcp_servers,
include_skill_authoring=True,
)
}
)
@self.route('/<tool_name>', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
async def _(tool_name: str) -> str:
"""获取特定工具详情"""
tools = await self.ap.tool_mgr.get_all_tools()
tools = await self.ap.tool_mgr.get_all_tools(include_skill_authoring=True)
for tool in tools:
if tool.name == tool_name:
@@ -195,6 +195,13 @@ class UserRouterGroup(group.RouterGroup):
@self.route('/set-password', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
async def _(user_email: str) -> str:
"""Set password for Space account (first time) or change password"""
# Check if modifying login info is allowed
allow_modify_login_info = self.ap.instance_config.data.get('system', {}).get(
'allow_modify_login_info', True
)
if not allow_modify_login_info:
return self.http_status(403, -1, 'Modifying login info is disabled')
json_data = await quart.request.json
new_password = json_data.get('new_password')
current_password = json_data.get('current_password')
+32
View File
@@ -199,3 +199,35 @@ class BotService:
# Send message via adapter
await runtime_bot.adapter.send_message(target_type, str(target_id), message_chain)
# ============ Bot Admins ============
async def get_bot_admins(self, bot_uuid: str) -> list[dict]:
from ....entity.persistence import bot as persistence_bot
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_bot.BotAdmin).where(persistence_bot.BotAdmin.bot_uuid == bot_uuid)
)
return [{'id': r.id, 'launcher_type': r.launcher_type, 'launcher_id': r.launcher_id} for r in result.all()]
async def add_bot_admin(self, bot_uuid: str, launcher_type: str, launcher_id: str) -> int:
from ....entity.persistence import bot as persistence_bot
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.insert(persistence_bot.BotAdmin).values(
bot_uuid=bot_uuid,
launcher_type=launcher_type,
launcher_id=launcher_id,
)
)
return result.inserted_primary_key[0]
async def delete_bot_admin(self, bot_uuid: str, admin_id: int) -> None:
from ....entity.persistence import bot as persistence_bot
await self.ap.persistence_mgr.execute_async(
sqlalchemy.delete(persistence_bot.BotAdmin).where(
persistence_bot.BotAdmin.bot_uuid == bot_uuid,
persistence_bot.BotAdmin.id == admin_id,
)
)
@@ -243,6 +243,7 @@ class MaintenanceService:
tables = {
'messages': persistence_monitoring.MonitoringMessage.id,
'llm_calls': persistence_monitoring.MonitoringLLMCall.id,
'tool_calls': persistence_monitoring.MonitoringToolCall.id,
'embedding_calls': persistence_monitoring.MonitoringEmbeddingCall.id,
'errors': persistence_monitoring.MonitoringError.id,
'sessions': persistence_monitoring.MonitoringSession.session_id,
+67 -2
View File
@@ -48,6 +48,17 @@ class MCPService:
if total_extensions >= max_extensions:
raise ValueError(f'Maximum number of extensions ({max_extensions}) reached')
server_name = str(server_data.get('name') or '').strip()
if not server_name:
raise ValueError('MCP server name is required')
server_data['name'] = server_name
existing_result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_mcp.MCPServer).where(persistence_mcp.MCPServer.name == server_name)
)
if existing_result.first() is not None:
raise ValueError(f'MCP server already exists: {server_name}')
server_data['uuid'] = str(uuid.uuid4())
await self.ap.persistence_mgr.execute_async(sqlalchemy.insert(persistence_mcp.MCPServer).values(server_data))
@@ -136,6 +147,32 @@ class MCPService:
if server_name in self.ap.tool_mgr.mcp_tool_loader.sessions:
await self.ap.tool_mgr.mcp_tool_loader.remove_mcp_server(server_name)
async def get_mcp_server_resources(self, server_name: str) -> list[dict]:
"""Get resources from a specific MCP server."""
return await self.ap.tool_mgr.mcp_tool_loader.get_resources(server_name)
async def get_mcp_server_resource_templates(self, server_name: str) -> list[dict]:
"""Get resource templates from a specific MCP server."""
return await self.ap.tool_mgr.mcp_tool_loader.get_resource_templates(server_name)
async def read_mcp_server_resource_envelope(
self,
server_name: str,
uri: str,
*,
max_bytes: int | None = None,
include_blob: bool = False,
) -> dict:
"""Read a resource from a specific MCP server with metadata."""
kwargs = {'include_blob': include_blob, 'source': 'ui_preview'}
if max_bytes is not None:
kwargs['max_bytes'] = max_bytes
return await self.ap.tool_mgr.mcp_tool_loader.read_resource_envelope(server_name, uri, **kwargs)
async def read_mcp_server_resource(self, server_name: str, uri: str) -> list[dict]:
"""Read a resource from a specific MCP server."""
return await self.ap.tool_mgr.mcp_tool_loader.read_resource(server_name, uri)
async def test_mcp_server(self, server_name: str, server_data: dict) -> int:
"""测试 MCP 服务器连接并返回任务 ID"""
@@ -151,10 +188,22 @@ class MCPService:
persisted_session = runtime_mcp_session
async def _refresh_and_report() -> None:
if persisted_session.status == MCPSessionStatus.ERROR:
# Testing a persisted server should REUSE its live shared-session
# process, not rebuild it. Try a lightweight refresh (a real
# list_tools probe over the existing connection) first; only fall
# back to a full start() when the session has no live connection
# to probe (never connected, or the process is actually gone).
needs_start = persisted_session.status == MCPSessionStatus.ERROR or persisted_session.session is None
if needs_start:
await persisted_session.start()
else:
await persisted_session.refresh()
try:
await persisted_session.refresh()
except Exception:
# The live connection was stale/dropped: reconnect once
# (reusing the live managed process where possible) and
# re-probe, instead of reporting a false failure.
await persisted_session.start()
# Surface the discovered tools so the config page can render them
# even for an already-hosted server.
ctx.metadata['runtime_info'] = persisted_session.get_runtime_info_dict()
@@ -195,3 +244,19 @@ class MCPService:
context=ctx,
)
return wrapper.id
async def get_mcp_server_logs(self, server_name: str, limit: int = 200, level: str | None = None) -> list[dict]:
"""Get recent log lines captured from the MCP server's stderr."""
session = self.ap.tool_mgr.mcp_tool_loader.get_session(server_name)
if not session:
return []
# Get logs from the session's buffer
logs = list(session._log_buffer)
# Filter by level if specified
if level:
logs = [log for log in logs if log.get('level') == level]
# Return the most recent 'limit' logs
return logs[-limit:]
@@ -2,6 +2,7 @@ from __future__ import annotations
import uuid
import datetime
import json
import sqlalchemy
from ....core import app
@@ -50,6 +51,12 @@ class MonitoringService:
persistence_monitoring.MonitoringLLMCall.timestamp,
persistence_monitoring.MonitoringLLMCall.id,
),
(
'monitoring_tool_calls',
persistence_monitoring.MonitoringToolCall,
persistence_monitoring.MonitoringToolCall.timestamp,
persistence_monitoring.MonitoringToolCall.id,
),
(
'monitoring_embedding_calls',
persistence_monitoring.MonitoringEmbeddingCall,
@@ -131,6 +138,68 @@ class MonitoringService:
await autocommit_conn.execute(sqlalchemy.text('PRAGMA wal_checkpoint(TRUNCATE)'))
await autocommit_conn.execute(sqlalchemy.text('VACUUM'))
def _serialize_tool_payload(self, payload: object, max_length: int = 20000) -> str | None:
"""Serialize tool arguments/results for monitoring storage."""
if payload is None:
return None
if isinstance(payload, str):
text = payload
else:
try:
text = json.dumps(payload, ensure_ascii=False, default=str)
except Exception:
text = str(payload)
if len(text) <= max_length:
return text
return f'{text[:max_length]}... [truncated {len(text) - max_length} chars]'
async def _get_message_for_tool_context(
self,
message_id: str | None = None,
session_id: str | None = None,
):
if message_id:
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_monitoring.MonitoringMessage).where(
persistence_monitoring.MonitoringMessage.id == message_id
)
)
row = result.first()
if row:
return row[0]
if not session_id:
return None
user_query = (
sqlalchemy.select(persistence_monitoring.MonitoringMessage)
.where(
sqlalchemy.and_(
persistence_monitoring.MonitoringMessage.session_id == session_id,
persistence_monitoring.MonitoringMessage.role == 'user',
)
)
.order_by(persistence_monitoring.MonitoringMessage.timestamp.desc())
.limit(1)
)
result = await self.ap.persistence_mgr.execute_async(user_query)
row = result.first()
if row:
return row[0]
any_query = (
sqlalchemy.select(persistence_monitoring.MonitoringMessage)
.where(persistence_monitoring.MonitoringMessage.session_id == session_id)
.order_by(persistence_monitoring.MonitoringMessage.timestamp.desc())
.limit(1)
)
result = await self.ap.persistence_mgr.execute_async(any_query)
row = result.first()
return row[0] if row else None
# ========== Recording Methods ==========
async def record_message(
@@ -220,6 +289,57 @@ class MonitoringService:
return call_id
async def record_tool_call(
self,
tool_name: str,
tool_source: str,
duration: int,
status: str = 'success',
bot_id: str | None = None,
bot_name: str | None = None,
pipeline_id: str | None = None,
pipeline_name: str | None = None,
session_id: str | None = None,
message_id: str | None = None,
arguments: object | None = None,
result: object | None = None,
error_message: str | None = None,
) -> str:
"""Record a tool call."""
context_message = await self._get_message_for_tool_context(message_id=message_id, session_id=session_id)
if context_message:
bot_id = bot_id or context_message.bot_id
bot_name = bot_name or context_message.bot_name
pipeline_id = pipeline_id or context_message.pipeline_id
pipeline_name = pipeline_name or context_message.pipeline_name
session_id = session_id or context_message.session_id
message_id = message_id or context_message.id
call_id = str(uuid.uuid4())
call_data = {
'id': call_id,
'timestamp': datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None),
'tool_name': tool_name,
'tool_source': tool_source,
'duration': max(0, duration),
'status': status,
'bot_id': bot_id or 'unknown',
'bot_name': bot_name or 'Unknown',
'pipeline_id': pipeline_id or 'unknown',
'pipeline_name': pipeline_name or 'Unknown',
'session_id': session_id,
'message_id': message_id,
'arguments': self._serialize_tool_payload(arguments),
'result': self._serialize_tool_payload(result),
'error_message': self._serialize_tool_payload(error_message),
}
await self.ap.persistence_mgr.execute_async(
sqlalchemy.insert(persistence_monitoring.MonitoringToolCall).values(call_data)
)
return call_id
async def record_embedding_call(
self,
model_name: str,
@@ -749,6 +869,58 @@ class MonitoringService:
total,
)
async def get_tool_calls(
self,
bot_ids: list[str] | None = None,
pipeline_ids: list[str] | None = None,
session_ids: list[str] | None = None,
start_time: datetime.datetime | None = None,
end_time: datetime.datetime | None = None,
limit: int = 100,
offset: int = 0,
) -> tuple[list[dict], int]:
"""Get tool calls with filters"""
conditions = []
if bot_ids:
conditions.append(persistence_monitoring.MonitoringToolCall.bot_id.in_(bot_ids))
if pipeline_ids:
conditions.append(persistence_monitoring.MonitoringToolCall.pipeline_id.in_(pipeline_ids))
if session_ids:
conditions.append(persistence_monitoring.MonitoringToolCall.session_id.in_(session_ids))
if start_time:
conditions.append(persistence_monitoring.MonitoringToolCall.timestamp >= start_time)
if end_time:
conditions.append(persistence_monitoring.MonitoringToolCall.timestamp <= end_time)
count_query = sqlalchemy.select(sqlalchemy.func.count(persistence_monitoring.MonitoringToolCall.id))
if conditions:
count_query = count_query.where(sqlalchemy.and_(*conditions))
count_result = await self.ap.persistence_mgr.execute_async(count_query)
total = count_result.scalar() or 0
query = sqlalchemy.select(persistence_monitoring.MonitoringToolCall).order_by(
persistence_monitoring.MonitoringToolCall.timestamp.desc()
)
if conditions:
query = query.where(sqlalchemy.and_(*conditions))
query = query.limit(limit).offset(offset)
result = await self.ap.persistence_mgr.execute_async(query)
tool_calls_rows = result.all()
return (
[
self.ap.persistence_mgr.serialize_model(
persistence_monitoring.MonitoringToolCall, row[0] if isinstance(row, tuple) else row
)
for row in tool_calls_rows
],
total,
)
async def get_embedding_calls(
self,
start_time: datetime.datetime | None = None,
@@ -971,6 +1143,34 @@ class MonitoringService:
else:
error_llm_calls += 1
# Get tool calls for this session
tool_query = (
sqlalchemy.select(persistence_monitoring.MonitoringToolCall)
.where(persistence_monitoring.MonitoringToolCall.session_id == session_id)
.order_by(persistence_monitoring.MonitoringToolCall.timestamp.asc())
)
tool_result = await self.ap.persistence_mgr.execute_async(tool_query)
tool_rows = tool_result.all()
tool_calls = [
self.ap.persistence_mgr.serialize_model(
persistence_monitoring.MonitoringToolCall, row[0] if isinstance(row, tuple) else row
)
for row in tool_rows
]
total_tool_calls = len(tool_rows)
success_tool_calls = 0
error_tool_calls = 0
total_tool_duration = 0
for row in tool_rows:
tool_call = row[0] if isinstance(row, tuple) else row
total_tool_duration += tool_call.duration
if tool_call.status == 'success':
success_tool_calls += 1
else:
error_tool_calls += 1
# Get errors for this session
error_query = (
sqlalchemy.select(persistence_monitoring.MonitoringError)
@@ -1014,6 +1214,14 @@ class MonitoringService:
'total_tokens': total_tokens,
'average_duration_ms': int(total_duration / total_llm_calls) if total_llm_calls > 0 else 0,
},
'tool_calls': tool_calls,
'tool_stats': {
'total_calls': total_tool_calls,
'success_calls': success_tool_calls,
'error_calls': error_tool_calls,
'total_duration_ms': total_tool_duration,
'average_duration_ms': int(total_tool_duration / total_tool_calls) if total_tool_calls > 0 else 0,
},
'errors': errors,
'session_duration_seconds': session_duration_seconds,
}
@@ -100,6 +100,8 @@ class PipelineService:
'enable_all_mcp_servers': True,
'plugins': [],
'mcp_servers': [],
'mcp_resources': [],
'mcp_resource_agent_read_enabled': True,
}
await self.ap.persistence_mgr.execute_async(
@@ -193,6 +195,8 @@ class PipelineService:
'enable_all_mcp_servers': True,
'plugins': [],
'mcp_servers': [],
'mcp_resources': [],
'mcp_resource_agent_read_enabled': True,
}
),
}
@@ -217,6 +221,8 @@ class PipelineService:
enable_all_mcp_servers: bool = True,
bound_skills: list[str] = None,
enable_all_skills: bool = True,
bound_mcp_resources: list[dict] = None,
mcp_resource_agent_read_enabled: bool | None = None,
) -> None:
"""Update the bound plugins and MCP servers for a pipeline"""
# Get current pipeline
@@ -236,10 +242,14 @@ class PipelineService:
extensions_preferences['enable_all_mcp_servers'] = enable_all_mcp_servers
extensions_preferences['enable_all_skills'] = enable_all_skills
extensions_preferences['plugins'] = bound_plugins
if mcp_resource_agent_read_enabled is not None:
extensions_preferences['mcp_resource_agent_read_enabled'] = mcp_resource_agent_read_enabled
if bound_mcp_servers is not None:
extensions_preferences['mcp_servers'] = bound_mcp_servers
if bound_skills is not None:
extensions_preferences['skills'] = bound_skills
if bound_mcp_resources is not None:
extensions_preferences['mcp_resources'] = bound_mcp_resources
await self.ap.persistence_mgr.execute_async(
sqlalchemy.update(persistence_pipeline.LegacyPipeline)
+11 -1
View File
@@ -84,7 +84,17 @@ class CommandManager:
privilege = 1
if f'{query.launcher_type.value}_{query.launcher_id}' in self.ap.instance_config.data['admins']:
import sqlalchemy as _sa
from ..entity.persistence.bot import BotAdmin as _BotAdmin
_admins = await self.ap.persistence_mgr.execute_async(
_sa.select(_BotAdmin).where(
_BotAdmin.bot_uuid == (query.bot_uuid or ''),
_BotAdmin.launcher_type == query.launcher_type.value,
_BotAdmin.launcher_id == str(query.launcher_id),
)
)
if _admins.first() is not None:
privilege = 2
ctx = command_context.ExecuteContext(
+14
View File
@@ -3,6 +3,20 @@ import sqlalchemy
from .base import Base
class BotAdmin(Base):
"""Bot admin — a launcher that has admin privilege for a specific bot's commands"""
__tablename__ = 'bot_admins'
id = sqlalchemy.Column(sqlalchemy.Integer, primary_key=True, autoincrement=True)
bot_uuid = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
launcher_type = sqlalchemy.Column(sqlalchemy.String(64), nullable=False)
launcher_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now())
__table_args__ = (sqlalchemy.UniqueConstraint('bot_uuid', 'launcher_type', 'launcher_id', name='uq_bot_admin'),)
class Bot(Base):
"""Bot"""
@@ -49,6 +49,28 @@ class MonitoringLLMCall(Base):
message_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True) # Associated message ID
class MonitoringToolCall(Base):
"""Tool call records"""
__tablename__ = 'monitoring_tool_calls'
id = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True)
timestamp = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, index=True)
tool_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
tool_source = sqlalchemy.Column(sqlalchemy.String(50), nullable=False) # native, plugin, mcp, skill
duration = sqlalchemy.Column(sqlalchemy.Integer, nullable=False) # milliseconds
status = sqlalchemy.Column(sqlalchemy.String(50), nullable=False) # success, error
bot_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, index=True)
bot_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
pipeline_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, index=True)
pipeline_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
session_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True)
message_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True)
arguments = sqlalchemy.Column(sqlalchemy.Text, nullable=True)
result = sqlalchemy.Column(sqlalchemy.Text, nullable=True)
error_message = sqlalchemy.Column(sqlalchemy.Text, nullable=True)
class MonitoringSession(Base):
"""Session tracking records"""
@@ -26,7 +26,14 @@ class LegacyPipeline(Base):
extensions_preferences = sqlalchemy.Column(
sqlalchemy.JSON,
nullable=False,
default={'enable_all_plugins': True, 'enable_all_mcp_servers': True, 'plugins': [], 'mcp_servers': []},
default={
'enable_all_plugins': True,
'enable_all_mcp_servers': True,
'plugins': [],
'mcp_servers': [],
'mcp_resources': [],
'mcp_resource_agent_read_enabled': True,
},
)
@@ -0,0 +1,84 @@
"""add bot_admins table and migrate config admins
Revision ID: 0007_add_bot_admins
Revises: 0006_normalize_mcp_remote_mode
Create Date: 2026-06-26
"""
import sqlalchemy as sa
from alembic import op
revision = '0007_add_bot_admins'
down_revision = '0006_normalize_mcp_remote_mode'
branch_labels = None
depends_on = None
def upgrade() -> None:
conn = op.get_bind()
if 'bot_admins' in sa.inspect(conn).get_table_names():
return
op.create_table(
'bot_admins',
sa.Column('id', sa.Integer, primary_key=True, autoincrement=True),
sa.Column('bot_uuid', sa.String(255), nullable=False),
sa.Column('launcher_type', sa.String(64), nullable=False),
sa.Column('launcher_id', sa.String(255), nullable=False),
sa.Column('created_at', sa.DateTime, nullable=False, server_default=sa.func.now()),
sa.UniqueConstraint('bot_uuid', 'launcher_type', 'launcher_id', name='uq_bot_admin'),
)
# Migrate old config-based admins into the first bot (best-effort)
inspector = sa.inspect(conn)
tables = inspector.get_table_names()
if 'bots' not in tables:
return
# Read the first bot uuid
row = conn.execute(sa.text('SELECT uuid FROM bots ORDER BY created_at LIMIT 1')).first()
if row is None:
return
first_bot_uuid = row[0]
# Read instance_config metadata key that holds the admins list
if 'metadata' not in tables:
return
meta_row = conn.execute(sa.text("SELECT value FROM metadata WHERE key = 'instance_config'")).first()
if meta_row is None:
return
import json
try:
cfg = json.loads(meta_row[0])
except Exception:
return
admins = cfg.get('admins', [])
for entry in admins:
parts = entry.split('_', 1)
if len(parts) != 2:
continue
launcher_type, launcher_id = parts
try:
conn.execute(
sa.text(
'INSERT OR IGNORE INTO bot_admins (bot_uuid, launcher_type, launcher_id) VALUES (:bu, :lt, :li)'
),
{'bu': first_bot_uuid, 'lt': launcher_type, 'li': launcher_id},
)
except Exception:
pass
# Remove admins key from stored config
if 'admins' in cfg:
del cfg['admins']
conn.execute(
sa.text("UPDATE metadata SET value = :v WHERE key = 'instance_config'"),
{'v': json.dumps(cfg)},
)
def downgrade() -> None:
op.drop_table('bot_admins')
@@ -0,0 +1,95 @@
"""add mcp resource preferences to pipelines
Revision ID: 0008_mcp_resource_prefs
Revises: 0007_add_bot_admins
Create Date: 2026-06-30
"""
from __future__ import annotations
import json
from typing import Any
import sqlalchemy as sa
from alembic import op
revision = '0008_mcp_resource_prefs'
down_revision = '0007_add_bot_admins'
branch_labels = None
depends_on = None
_PIPELINE_TABLE = sa.table(
'legacy_pipelines',
sa.column('uuid', sa.String(255)),
sa.column('extensions_preferences', sa.JSON()),
)
def _has_extensions_preferences_table(conn: sa.Connection) -> bool:
inspector = sa.inspect(conn)
if 'legacy_pipelines' not in inspector.get_table_names():
return False
columns = {column['name'] for column in inspector.get_columns('legacy_pipelines')}
return 'extensions_preferences' in columns
def _decode_preferences(value: Any) -> dict[str, Any]:
if value is None:
return {}
if isinstance(value, dict):
return dict(value)
if isinstance(value, str):
try:
decoded = json.loads(value)
except json.JSONDecodeError:
return {}
if isinstance(decoded, dict):
return decoded
return {}
def _update_preferences(conn: sa.Connection, uuid: str, preferences: dict[str, Any]) -> None:
conn.execute(
_PIPELINE_TABLE.update().where(_PIPELINE_TABLE.c.uuid == uuid).values(extensions_preferences=preferences)
)
def upgrade() -> None:
conn = op.get_bind()
if not _has_extensions_preferences_table(conn):
return
rows = conn.execute(sa.select(_PIPELINE_TABLE.c.uuid, _PIPELINE_TABLE.c.extensions_preferences)).all()
for uuid, raw_preferences in rows:
preferences = _decode_preferences(raw_preferences)
changed = False
if 'mcp_resources' not in preferences:
preferences['mcp_resources'] = []
changed = True
if 'mcp_resource_agent_read_enabled' not in preferences:
preferences['mcp_resource_agent_read_enabled'] = True
changed = True
if changed:
_update_preferences(conn, uuid, preferences)
def downgrade() -> None:
conn = op.get_bind()
if not _has_extensions_preferences_table(conn):
return
rows = conn.execute(sa.select(_PIPELINE_TABLE.c.uuid, _PIPELINE_TABLE.c.extensions_preferences)).all()
for uuid, raw_preferences in rows:
preferences = _decode_preferences(raw_preferences)
changed = False
for key in ('mcp_resources', 'mcp_resource_agent_read_enabled'):
if key in preferences:
preferences.pop(key)
changed = True
if changed:
_update_preferences(conn, uuid, preferences)
@@ -0,0 +1,17 @@
from langbot.pkg.entity.persistence import monitoring as persistence_monitoring
from .. import migration
@migration.migration_class(26)
class DBMigrateMonitoringToolCalls(migration.DBMigration):
"""Add monitoring_tool_calls table"""
async def upgrade(self):
"""Upgrade"""
async with self.ap.persistence_mgr.get_db_engine().begin() as conn:
await conn.run_sync(persistence_monitoring.MonitoringToolCall.__table__.create, checkfirst=True)
async def downgrade(self):
"""Downgrade"""
async with self.ap.persistence_mgr.get_db_engine().begin() as conn:
await conn.run_sync(persistence_monitoring.MonitoringToolCall.__table__.drop, checkfirst=True)
@@ -32,7 +32,7 @@ class MonitoringHelper:
"""Record the start of query processing, returns message_id"""
try:
# Check if session exists, if not, record session start
session_id = f'{query.launcher_type}_{query.launcher_id}'
session_id = f'{query.launcher_type.value if hasattr(query.launcher_type, "value") else query.launcher_type}_{query.launcher_id}'
# Get sender name from message event
sender_name = None
@@ -137,7 +137,7 @@ class MonitoringHelper:
):
"""Record bot response message to monitoring"""
try:
session_id = f'{query.launcher_type}_{query.launcher_id}'
session_id = f'{query.launcher_type.value if hasattr(query.launcher_type, "value") else query.launcher_type}_{query.launcher_id}'
# Get sender name from message event
sender_name = None
@@ -202,7 +202,7 @@ class MonitoringHelper:
) -> str:
"""Record query processing error, returns message_id"""
try:
session_id = f'{query.launcher_type}_{query.launcher_id}'
session_id = f'{query.launcher_type.value if hasattr(query.launcher_type, "value") else query.launcher_type}_{query.launcher_id}'
# Get sender name from message event
sender_name = None
@@ -268,7 +268,7 @@ class MonitoringHelper:
):
"""Record LLM call"""
try:
session_id = f'{query.launcher_type}_{query.launcher_id}'
session_id = f'{query.launcher_type.value if hasattr(query.launcher_type, "value") else query.launcher_type}_{query.launcher_id}'
await ap.monitoring_service.record_llm_call(
bot_id=bot_id,
+13 -2
View File
@@ -96,6 +96,15 @@ class RuntimePipeline:
extensions_prefs = pipeline_entity.extensions_preferences or {}
self.enable_all_plugins = extensions_prefs.get('enable_all_plugins', True)
self.enable_all_mcp_servers = extensions_prefs.get('enable_all_mcp_servers', True)
local_agent_config = (pipeline_entity.config or {}).get('ai', {}).get('local-agent', {})
self.mcp_resource_attachments = local_agent_config.get(
'mcp-resources',
extensions_prefs.get('mcp_resources', []),
)
self.mcp_resource_agent_read_enabled = local_agent_config.get(
'mcp-resource-agent-read-enabled',
extensions_prefs.get('mcp_resource_agent_read_enabled', True),
)
if self.enable_all_plugins:
# None indicates to use all available plugins
@@ -116,6 +125,8 @@ class RuntimePipeline:
# Store bound plugins and MCP servers in query for filtering
query.variables['_pipeline_bound_plugins'] = self.bound_plugins
query.variables['_pipeline_bound_mcp_servers'] = self.bound_mcp_servers
query.variables['_pipeline_mcp_resource_attachments'] = self.mcp_resource_attachments
query.variables['_pipeline_mcp_resource_agent_read_enabled'] = self.mcp_resource_agent_read_enabled
# Record query start for monitoring
try:
@@ -157,7 +168,7 @@ class RuntimePipeline:
bot_message=query.resp_messages[-1],
message=result.user_notice,
quote_origin=query.pipeline_config['output']['misc']['quote-origin'],
is_final=[msg.is_final for msg in query.resp_messages][0],
is_final=[msg.is_final for msg in query.resp_messages][-1],
)
else:
await query.adapter.reply_message(
@@ -178,7 +189,7 @@ class RuntimePipeline:
bot_name = query.variables.get('_monitoring_bot_name', 'Unknown')
pipeline_name = query.variables.get('_monitoring_pipeline_name', 'Unknown')
message_id = query.variables.get('_monitoring_message_id', '')
session_id = f'{query.launcher_type}_{query.launcher_id}'
session_id = f'{query.launcher_type.value if hasattr(query.launcher_type, "value") else query.launcher_type}_{query.launcher_id}'
# Update message status to error
if message_id:
@@ -0,0 +1,274 @@
from __future__ import annotations
import traceback
import weakref
from dataclasses import dataclass, field
from typing import Any
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
import langbot_plugin.api.entities.builtin.platform.message as platform_message
@dataclass(frozen=True)
class PluginResponseSource:
plugin: dict[str, str]
event_name: str | None = None
is_approximate: bool = False
@dataclass
class QueryDiagnosticState:
pending_by_chain_id: dict[int, list[PluginResponseSource]] = field(default_factory=dict)
by_response_index: dict[int, list[PluginResponseSource]] = field(default_factory=dict)
finalizer: weakref.finalize | None = None
_QUERY_STATES: dict[int, QueryDiagnosticState] = {}
def record_plugin_response_source(
query: pipeline_query.Query,
response_index: int,
response_sources: list[dict[str, Any]] | None,
emitted_plugins: list[dict[str, Any]] | None = None,
event_name: str | None = None,
) -> None:
plugin_sources = _build_plugin_sources(response_sources, emitted_plugins, event_name)
if not plugin_sources:
return
state = _get_or_create_query_state(query)
state.by_response_index[response_index] = plugin_sources
def record_last_plugin_response_source(
query: pipeline_query.Query,
response_sources: list[dict[str, Any]] | None,
emitted_plugins: list[dict[str, Any]] | None = None,
event_name: str | None = None,
) -> None:
record_plugin_response_source(
query,
len(query.resp_message_chain) - 1,
response_sources,
emitted_plugins,
event_name,
)
def record_pending_plugin_response_source(
query: pipeline_query.Query,
message_chain: platform_message.MessageChain,
response_sources: list[dict[str, Any]] | None,
emitted_plugins: list[dict[str, Any]] | None = None,
event_name: str | None = None,
) -> None:
plugin_sources = _build_plugin_sources(response_sources, emitted_plugins, event_name)
if not plugin_sources:
return
state = _get_or_create_query_state(query)
state.pending_by_chain_id[id(message_chain)] = plugin_sources
def consume_pending_plugin_response_source(
query: pipeline_query.Query,
message_chain: platform_message.MessageChain,
response_index: int,
) -> None:
state = _get_query_state(query)
if state is None:
return
source = state.pending_by_chain_id.pop(id(message_chain), None)
if source is None:
return
state.by_response_index[response_index] = source
def clear_response_source(query: pipeline_query.Query, response_index: int) -> None:
state = _get_query_state(query)
if state is None:
return
state.by_response_index.pop(response_index, None)
_discard_query_state_if_empty(query)
async def notify_response_delivery_failure(
ap: Any,
query: pipeline_query.Query,
response_index: int,
message_chain: platform_message.MessageChain,
error: Exception,
) -> None:
try:
plugin_refs = _get_response_sources(query, response_index)
if not plugin_refs:
return
connector = getattr(ap, 'plugin_connector', None)
if connector is None or not hasattr(connector, 'notify_plugin_diagnostic'):
return
for source in plugin_refs:
payload = _build_delivery_failure_payload(
plugin_ref=source.plugin,
event_name=source.event_name,
is_approximate=source.is_approximate,
query=query,
response_index=response_index,
message_chain=message_chain,
error=error,
)
try:
await connector.notify_plugin_diagnostic(payload)
except Exception as diag_error:
_debug(ap, f'Plugin diagnostic forwarding failed: {diag_error}')
except Exception as diag_error:
_debug(ap, f'Plugin diagnostic forwarding skipped: {diag_error}')
def get_emitted_plugins(event_ctx: Any) -> list[dict[str, Any]]:
emitted_plugins = getattr(event_ctx, '_emitted_plugins', [])
return emitted_plugins if isinstance(emitted_plugins, list) else []
def get_response_sources(event_ctx: Any) -> list[dict[str, Any]] | None:
event_attrs = vars(event_ctx)
if '_response_sources' not in event_attrs:
return None
response_sources = event_attrs['_response_sources']
return response_sources if isinstance(response_sources, list) else []
def _get_or_create_query_state(query: pipeline_query.Query) -> QueryDiagnosticState:
query_key = id(query)
state = _QUERY_STATES.get(query_key)
if state is not None:
return state
state = QueryDiagnosticState()
try:
state.finalizer = weakref.finalize(query, _discard_query_state, query_key)
except TypeError:
state.finalizer = None
_QUERY_STATES[query_key] = state
return state
def _get_query_state(query: pipeline_query.Query) -> QueryDiagnosticState | None:
return _QUERY_STATES.get(id(query))
def _discard_query_state(query_key: int) -> None:
_QUERY_STATES.pop(query_key, None)
def _discard_query_state_if_empty(query: pipeline_query.Query) -> None:
query_key = id(query)
state = _QUERY_STATES.get(query_key)
if state is None:
return
if state.pending_by_chain_id or state.by_response_index:
return
if state.finalizer is not None:
state.finalizer.detach()
_discard_query_state(query_key)
def _get_response_sources(
query: pipeline_query.Query,
response_index: int,
) -> list[PluginResponseSource]:
state = _get_query_state(query)
if state is None:
return []
return state.by_response_index.get(response_index, [])
def _extract_plugin_ref(plugin: Any) -> dict[str, str] | None:
manifest = plugin.get('manifest') if isinstance(plugin, dict) else None
metadata = manifest.get('metadata') if isinstance(manifest, dict) else None
if not isinstance(metadata, dict):
return None
author = metadata.get('author')
name = metadata.get('name')
if not author or not name:
return None
return {'author': str(author), 'name': str(name)}
def _extract_response_source_plugin_ref(source: Any) -> dict[str, str] | None:
if not isinstance(source, dict):
return None
if source.get('kind') != 'reply_message_chain':
return None
plugin_ref = source.get('plugin')
if not isinstance(plugin_ref, dict):
return None
author = plugin_ref.get('author')
name = plugin_ref.get('name')
if not author or not name:
return None
return {'author': str(author), 'name': str(name)}
def _build_plugin_sources(
response_sources: list[dict[str, Any]] | None,
emitted_plugins: list[dict[str, Any]] | None,
event_name: str | None,
) -> list[PluginResponseSource]:
if response_sources is not None:
plugin_refs = [_extract_response_source_plugin_ref(source) for source in response_sources]
return [
PluginResponseSource(plugin=plugin, event_name=event_name) for plugin in plugin_refs if plugin is not None
]
if emitted_plugins:
plugin_refs = [_extract_plugin_ref(plugin) for plugin in emitted_plugins]
return [
PluginResponseSource(plugin=plugin, event_name=event_name, is_approximate=True)
for plugin in plugin_refs
if plugin is not None
]
return []
def _debug(ap: Any, message: str) -> None:
logger = getattr(ap, 'logger', None)
if logger is not None:
logger.debug(message)
def _build_delivery_failure_payload(
plugin_ref: dict[str, str],
event_name: str | None,
is_approximate: bool,
query: pipeline_query.Query,
response_index: int,
message_chain: platform_message.MessageChain,
error: Exception,
) -> dict[str, Any]:
details: dict[str, Any] = {
'message_component_types': [component.__class__.__name__ for component in message_chain],
'message_preview': str(message_chain)[:200],
}
if is_approximate:
details['attribution_warning'] = (
'This diagnostic was delivered to all plugins that handled the event because the '
'plugin runtime did not report the exact reply_message_chain source.'
)
return {
'level': 'ERROR',
'code': 'response_delivery_failed',
'message': 'Failed to deliver a plugin-provided response message.',
'plugin': plugin_ref,
'query': {
'query_id': query.query_id,
'event_name': event_name or query.message_event.__class__.__name__,
'stage': query.current_stage_name or 'SendResponseBackStage',
'response_index': response_index,
},
'details': details,
'delivery': {
'error_type': error.__class__.__name__,
'error_message': str(error),
'traceback': traceback.format_exception_only(type(error), error)[-1].strip(),
},
}
+5 -1
View File
@@ -42,9 +42,13 @@ class QueryPool:
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter,
pipeline_uuid: typing.Optional[str] = None,
routed_by_rule: bool = False,
variables: typing.Optional[dict[str, typing.Any]] = None,
) -> pipeline_query.Query:
async with self.condition:
query_id = self.query_id_counter
initial_variables: dict[str, typing.Any] = {'_routed_by_rule': routed_by_rule}
if variables:
initial_variables.update(variables)
query = pipeline_query.Query(
bot_uuid=bot_uuid,
query_id=query_id,
@@ -53,7 +57,7 @@ class QueryPool:
sender_id=sender_id,
message_event=message_event,
message_chain=message_chain,
variables={'_routed_by_rule': routed_by_rule},
variables=initial_variables,
resp_messages=[],
resp_message_chain=[],
adapter=adapter,
+25 -3
View File
@@ -25,6 +25,21 @@ class PreProcessor(stage.PipelineStage):
- use_funcs
"""
@staticmethod
def _filter_selected_tools(
tools: list,
local_agent_config: dict,
) -> list:
if local_agent_config.get('enable-all-tools', True) is not False:
return tools
selected_tools = local_agent_config.get('tools', [])
if not isinstance(selected_tools, list):
return []
selected_tool_names = {tool for tool in selected_tools if isinstance(tool, str)}
return [tool for tool in tools if tool.name in selected_tool_names]
async def process(
self,
query: pipeline_query.Query,
@@ -32,6 +47,7 @@ class PreProcessor(stage.PipelineStage):
) -> entities.StageProcessResult:
"""Process"""
selected_runner = query.pipeline_config['ai']['runner']['runner']
local_agent_config = query.pipeline_config.get('ai', {}).get('local-agent', {})
include_skill_authoring = (
selected_runner == 'local-agent' and getattr(self.ap, 'skill_service', None) is not None
)
@@ -43,7 +59,7 @@ class PreProcessor(stage.PipelineStage):
if selected_runner == 'local-agent':
# Read model config — new format is { primary: str, fallbacks: [str] },
# but handle legacy plain string for backward compatibility
model_config = query.pipeline_config['ai']['local-agent'].get('model', {})
model_config = local_agent_config.get('model', {})
if isinstance(model_config, str):
# Legacy format: plain UUID string
primary_uuid = model_config
@@ -113,11 +129,14 @@ class PreProcessor(stage.PipelineStage):
# Get bound plugins and MCP servers for filtering tools
bound_plugins = query.variables.get('_pipeline_bound_plugins', None)
bound_mcp_servers = query.variables.get('_pipeline_bound_mcp_servers', None)
query.use_funcs = await self.ap.tool_mgr.get_all_tools(
include_mcp_resource_tools = query.variables.get('_pipeline_mcp_resource_agent_read_enabled', True)
all_tools = await self.ap.tool_mgr.get_all_tools(
bound_plugins,
bound_mcp_servers,
include_skill_authoring=include_skill_authoring,
include_mcp_resource_tools=include_mcp_resource_tools,
)
query.use_funcs = self._filter_selected_tools(all_tools, local_agent_config)
self.ap.logger.debug(f'Bound plugins: {bound_plugins}')
self.ap.logger.debug(f'Bound MCP servers: {bound_mcp_servers}')
@@ -128,11 +147,14 @@ class PreProcessor(stage.PipelineStage):
if not query.use_funcs and query.variables.get('_fallback_model_uuids'):
bound_plugins = query.variables.get('_pipeline_bound_plugins', None)
bound_mcp_servers = query.variables.get('_pipeline_bound_mcp_servers', None)
query.use_funcs = await self.ap.tool_mgr.get_all_tools(
include_mcp_resource_tools = query.variables.get('_pipeline_mcp_resource_agent_read_enabled', True)
all_tools = await self.ap.tool_mgr.get_all_tools(
bound_plugins,
bound_mcp_servers,
include_skill_authoring=include_skill_authoring,
include_mcp_resource_tools=include_mcp_resource_tools,
)
query.use_funcs = self._filter_selected_tools(all_tools, local_agent_config)
sender_name = ''
@@ -9,6 +9,7 @@ from datetime import datetime
from .. import handler
from ... import entities
from ... import plugin_diagnostics
from ....provider import runner as runner_module
import langbot_plugin.api.entities.events as events
@@ -58,6 +59,13 @@ class ChatMessageHandler(handler.MessageHandler):
if event_ctx.is_prevented_default():
if event_ctx.event.reply_message_chain is not None:
mc = event_ctx.event.reply_message_chain
plugin_diagnostics.record_pending_plugin_response_source(
query,
mc,
plugin_diagnostics.get_response_sources(event_ctx),
plugin_diagnostics.get_emitted_plugins(event_ctx),
event.event_name,
)
query.resp_messages.append(mc)
yield entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query)
@@ -4,6 +4,7 @@ import typing
from .. import handler
from ... import entities
from ... import plugin_diagnostics
import langbot_plugin.api.entities.builtin.provider.message as provider_message
import langbot_plugin.api.entities.builtin.provider.session as provider_session
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
@@ -52,6 +53,13 @@ class CommandHandler(handler.MessageHandler):
if event_ctx.is_prevented_default():
if event_ctx.event.reply_message_chain is not None:
mc = event_ctx.event.reply_message_chain
plugin_diagnostics.record_pending_plugin_response_source(
query,
mc,
plugin_diagnostics.get_response_sources(event_ctx),
plugin_diagnostics.get_emitted_plugins(event_ctx),
event.event_name,
)
query.resp_messages.append(mc)
+30 -14
View File
@@ -9,6 +9,7 @@ import langbot_plugin.api.entities.builtin.platform.message as platform_message
import langbot_plugin.api.entities.builtin.provider.message as provider_message
from .. import stage, entities
from .. import plugin_diagnostics
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
@@ -39,20 +40,35 @@ class SendResponseBackStage(stage.PipelineStage):
has_chunks = any(isinstance(msg, provider_message.MessageChunk) for msg in query.resp_messages)
# TODO 命令与流式的兼容性问题
if await query.adapter.is_stream_output_supported() and has_chunks:
is_final = [msg.is_final for msg in query.resp_messages][0]
await query.adapter.reply_message_chunk(
message_source=query.message_event,
bot_message=query.resp_messages[-1],
message=query.resp_message_chain[-1],
quote_origin=quote_origin,
is_final=is_final,
)
else:
await query.adapter.reply_message(
message_source=query.message_event,
message=query.resp_message_chain[-1],
quote_origin=quote_origin,
response_index = len(query.resp_message_chain) - 1
message_chain = query.resp_message_chain[-1]
try:
if await query.adapter.is_stream_output_supported() and has_chunks:
is_final = [msg.is_final for msg in query.resp_messages][-1]
await query.adapter.reply_message_chunk(
message_source=query.message_event,
bot_message=query.resp_messages[-1],
message=message_chain,
quote_origin=quote_origin,
is_final=is_final,
)
else:
await query.adapter.reply_message(
message_source=query.message_event,
message=message_chain,
quote_origin=quote_origin,
)
except Exception as e:
await plugin_diagnostics.notify_response_delivery_failure(
self.ap,
query,
response_index,
message_chain,
e,
)
plugin_diagnostics.clear_response_source(query, response_index)
raise
plugin_diagnostics.clear_response_source(query, response_index)
return entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query)
@@ -3,6 +3,7 @@ from __future__ import annotations
import typing
from .. import entities
from .. import plugin_diagnostics
from .. import stage
import langbot_plugin.api.entities.builtin.platform.message as platform_message
@@ -78,6 +79,11 @@ class ResponseWrapper(stage.PipelineStage):
# 如果 resp_messages[-1] 已经是 MessageChain 了
if isinstance(query.resp_messages[-1], platform_message.MessageChain):
query.resp_message_chain.append(query.resp_messages[-1])
plugin_diagnostics.consume_pending_plugin_response_source(
query,
query.resp_messages[-1],
len(query.resp_message_chain) - 1,
)
yield entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query)
@@ -129,8 +135,10 @@ class ResponseWrapper(stage.PipelineStage):
else:
if event_ctx.event.reply_message_chain is not None:
reply_chain = event_ctx.event.reply_message_chain
is_plugin_reply = True
else:
reply_chain = result.get_content_platform_message_chain()
is_plugin_reply = False
# Attach files the agent produced in the sandbox
# outbox, but only on the terminal assistant message.
@@ -138,6 +146,13 @@ class ResponseWrapper(stage.PipelineStage):
await self._append_outbound_attachments(query, reply_chain)
query.resp_message_chain.append(reply_chain)
if is_plugin_reply:
plugin_diagnostics.record_last_plugin_response_source(
query,
plugin_diagnostics.get_response_sources(event_ctx),
plugin_diagnostics.get_emitted_plugins(event_ctx),
event.event_name,
)
yield entities.StageProcessResult(
result_type=entities.ResultType.CONTINUE,
@@ -180,6 +195,12 @@ class ResponseWrapper(stage.PipelineStage):
else:
if event_ctx.event.reply_message_chain is not None:
query.resp_message_chain.append(event_ctx.event.reply_message_chain)
plugin_diagnostics.record_last_plugin_response_source(
query,
plugin_diagnostics.get_response_sources(event_ctx),
plugin_diagnostics.get_emitted_plugins(event_ctx),
event.event_name,
)
else:
query.resp_message_chain.append(
+2
View File
@@ -501,6 +501,8 @@ class PlatformManager:
bot_entity.adapter_config,
logger,
)
if hasattr(adapter_inst, 'ap'):
adapter_inst.ap = self.ap
# 如果 adapter 支持 set_bot_uuid 方法,设置 bot_uuid(用于统一 webhook
if hasattr(adapter_inst, 'set_bot_uuid'):
+149 -24
View File
@@ -4,6 +4,7 @@ import asyncio
import traceback
import datetime
import json
import time
import aiocqhttp
import pydantic
@@ -16,6 +17,37 @@ from ...utils import image
import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger
_GROUP_NAME_CACHE_TTL_SECONDS = 3600
_GROUP_NAME_NEGATIVE_CACHE_TTL_SECONDS = 60
_GROUP_NAME_LOOKUP_TIMEOUT_SECONDS = 2
_GROUP_MEMBER_INFO_CACHE_TTL_SECONDS = 86400
_GROUP_MEMBER_INFO_NEGATIVE_CACHE_TTL_SECONDS = 600
_GROUP_MEMBER_INFO_LOOKUP_TIMEOUT_SECONDS = 2
def _normalize_base64_payload(value: str) -> str:
if value.startswith('base64://'):
return value.removeprefix('base64://')
if value.startswith('data:') and ';base64,' in value:
return value.split(';base64,', 1)[1]
return value
def _get_field(data: dict, key: str, default: str = '') -> str:
value = data.get(key)
if value is None:
return default
return str(value)
def _get_group_member_name(sender: dict) -> str:
return _get_field(sender, 'card') or _get_field(sender, 'nickname') or _get_field(sender, 'user_id')
def _get_group_name_placeholder(group_id: typing.Union[int, str]) -> str:
return f'Group {group_id}'
class AiocqhttpMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
@staticmethod
async def yiri2target(
@@ -35,7 +67,7 @@ class AiocqhttpMessageConverter(abstract_platform_adapter.AbstractMessageConvert
elif type(msg) is platform_message.Image:
arg = ''
if msg.base64:
arg = msg.base64
arg = _normalize_base64_payload(msg.base64)
msg_list.append(aiocqhttp.MessageSegment.image(f'base64://{arg}'))
elif msg.url:
arg = msg.url
@@ -50,7 +82,7 @@ class AiocqhttpMessageConverter(abstract_platform_adapter.AbstractMessageConvert
elif type(msg) is platform_message.Voice:
arg = ''
if msg.base64:
arg = msg.base64
arg = _normalize_base64_payload(msg.base64)
msg_list.append(aiocqhttp.MessageSegment.record(f'base64://{arg}'))
elif msg.url:
arg = msg.url
@@ -62,7 +94,10 @@ class AiocqhttpMessageConverter(abstract_platform_adapter.AbstractMessageConvert
for node in msg.node_list:
msg_list.extend((await AiocqhttpMessageConverter.yiri2target(node.message_chain))[0])
elif isinstance(msg, platform_message.File):
msg_list.append({'type': 'file', 'data': {'file': msg.url, 'name': msg.name}})
file = msg.url or msg.path
if not file and msg.base64:
file = f'base64://{_normalize_base64_payload(msg.base64)}'
msg_list.append({'type': 'file', 'data': {'file': file, 'name': msg.name}})
elif isinstance(msg, platform_message.Face):
if msg.face_type == 'face':
msg_list.append(aiocqhttp.MessageSegment.face(msg.face_id))
@@ -324,16 +359,96 @@ class AiocqhttpMessageConverter(abstract_platform_adapter.AbstractMessageConvert
class AiocqhttpEventConverter(abstract_platform_adapter.AbstractEventConverter):
def __init__(self):
self._group_name_cache: dict[typing.Union[int, str], tuple[str, float]] = {}
self._group_name_negative_cache: dict[typing.Union[int, str], float] = {}
self._group_member_info_cache: dict[
tuple[typing.Union[int, str], typing.Union[int, str]], tuple[dict, float]
] = {}
self._group_member_info_negative_cache: dict[tuple[typing.Union[int, str], typing.Union[int, str]], float] = {}
@staticmethod
async def yiri2target(event: platform_events.MessageEvent, bot_account_id: int):
return event.source_platform_object
@staticmethod
async def target2yiri(event: aiocqhttp.Event, bot=None):
async def _get_group_name(self, group_id: typing.Union[int, str], bot=None) -> str:
now = time.monotonic()
if group_id in self._group_name_cache:
group_name, expires_at = self._group_name_cache[group_id]
if expires_at > now:
return group_name
del self._group_name_cache[group_id]
if group_id in self._group_name_negative_cache:
expires_at = self._group_name_negative_cache[group_id]
if expires_at > now:
return ''
del self._group_name_negative_cache[group_id]
if bot is None:
return ''
try:
group_info = await asyncio.wait_for(
bot.get_group_info(group_id=group_id),
timeout=_GROUP_NAME_LOOKUP_TIMEOUT_SECONDS,
)
except Exception:
self._group_name_negative_cache[group_id] = now + _GROUP_NAME_NEGATIVE_CACHE_TTL_SECONDS
return ''
group_name = _get_field(group_info, 'group_name') if isinstance(group_info, dict) else ''
if group_name:
self._group_name_cache[group_id] = (group_name, now + _GROUP_NAME_CACHE_TTL_SECONDS)
self._group_name_negative_cache.pop(group_id, None)
else:
self._group_name_negative_cache[group_id] = now + _GROUP_NAME_NEGATIVE_CACHE_TTL_SECONDS
return group_name
async def _get_group_member_info(
self,
group_id: typing.Union[int, str],
user_id: typing.Union[int, str],
bot=None,
) -> dict:
now = time.monotonic()
cache_key = (group_id, user_id)
if cache_key in self._group_member_info_cache:
member_info, expires_at = self._group_member_info_cache[cache_key]
if expires_at > now:
return member_info
del self._group_member_info_cache[cache_key]
if cache_key in self._group_member_info_negative_cache:
expires_at = self._group_member_info_negative_cache[cache_key]
if expires_at > now:
return {}
del self._group_member_info_negative_cache[cache_key]
if bot is None:
return {}
try:
member_info = await asyncio.wait_for(
bot.get_group_member_info(group_id=group_id, user_id=user_id),
timeout=_GROUP_MEMBER_INFO_LOOKUP_TIMEOUT_SECONDS,
)
except Exception:
self._group_member_info_negative_cache[cache_key] = now + _GROUP_MEMBER_INFO_NEGATIVE_CACHE_TTL_SECONDS
return {}
if isinstance(member_info, dict) and member_info:
self._group_member_info_cache[cache_key] = (
member_info,
now + _GROUP_MEMBER_INFO_CACHE_TTL_SECONDS,
)
self._group_member_info_negative_cache.pop(cache_key, None)
return member_info
self._group_member_info_negative_cache[cache_key] = now + _GROUP_MEMBER_INFO_NEGATIVE_CACHE_TTL_SECONDS
return {}
async def target2yiri(self, event: aiocqhttp.Event, bot=None):
yiri_chain = await AiocqhttpMessageConverter.target2yiri(event.message, event.message_id, bot)
if event.message_type == 'group':
permission = 'MEMBER'
group_name = await self._get_group_name(event.group_id, bot) or _get_group_name_placeholder(event.group_id)
special_title = _get_field(event.sender, 'title')
if not special_title:
member_info = await self._get_group_member_info(event.group_id, event.sender['user_id'], bot)
special_title = _get_field(member_info, 'title')
if 'role' in event.sender:
if event.sender['role'] == 'admin':
@@ -343,14 +458,14 @@ class AiocqhttpEventConverter(abstract_platform_adapter.AbstractEventConverter):
converted_event = platform_events.GroupMessage(
sender=platform_entities.GroupMember(
id=event.sender['user_id'], # message_seq 放哪?
member_name=event.sender['nickname'],
member_name=_get_group_member_name(event.sender),
permission=permission,
group=platform_entities.Group(
id=event.group_id,
name=event.sender['nickname'],
name=group_name,
permission=platform_entities.Permission.Member,
),
special_title=event.sender['title'] if 'title' in event.sender else '',
special_title=special_title,
),
message_chain=yiri_chain,
time=event.time,
@@ -374,9 +489,13 @@ class AiocqhttpAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
bot: aiocqhttp.CQHttp = pydantic.Field(exclude=True, default_factory=aiocqhttp.CQHttp)
message_converter: AiocqhttpMessageConverter = AiocqhttpMessageConverter()
event_converter: AiocqhttpEventConverter = AiocqhttpEventConverter()
event_converter: AiocqhttpEventConverter = pydantic.Field(default_factory=AiocqhttpEventConverter)
on_websocket_connection_event_cache: typing.List[typing.Callable[[aiocqhttp.Event], None]] = []
on_websocket_connection_event_cache: list[aiocqhttp.Event] = []
_listener_wrappers: dict[
tuple[typing.Type[platform_events.Event], typing.Callable],
tuple[str, typing.Callable],
] = {}
def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger):
super().__init__(
@@ -391,6 +510,7 @@ class AiocqhttpAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
self.config['shutdown_trigger'] = shutdown_trigger_placeholder
self.on_websocket_connection_event_cache = []
self._listener_wrappers = {}
if 'access-token' in config:
self.bot = aiocqhttp.CQHttp(access_token=config['access-token'])
@@ -398,6 +518,16 @@ class AiocqhttpAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
else:
self.bot = aiocqhttp.CQHttp()
self.bot.on_websocket_connection(self._on_websocket_connection)
async def _on_websocket_connection(self, event: aiocqhttp.Event):
for cached_event in self.on_websocket_connection_event_cache:
if cached_event.self_id == event.self_id and cached_event.time == event.time:
return
self.on_websocket_connection_event_cache.append(event)
await self.logger.info(f'WebSocket connection established, bot id: {event.self_id}')
async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):
# Check if message contains a Forward component
forward_msg = message.get_first(platform_message.Forward)
@@ -433,9 +563,7 @@ class AiocqhttpAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
elif isinstance(component, platform_message.Image):
img_data = {}
if component.base64:
b64 = component.base64
if b64.startswith('data:'):
b64 = b64.split(',', 1)[-1] if ',' in b64 else b64
b64 = _normalize_base64_payload(component.base64)
img_data['file'] = f'base64://{b64}'
elif component.url:
img_data['file'] = component.url
@@ -535,22 +663,14 @@ class AiocqhttpAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
if event_type == platform_events.GroupMessage:
self.bot.on_message('group')(on_message)
self._listener_wrappers[(event_type, callback)] = ('message.group', on_message)
# self.bot.on_notice()(on_message)
elif event_type == platform_events.FriendMessage:
self.bot.on_message('private')(on_message)
self._listener_wrappers[(event_type, callback)] = ('message.private', on_message)
# self.bot.on_notice()(on_message)
# print(event_type)
async def on_websocket_connection(event: aiocqhttp.Event):
for event in self.on_websocket_connection_event_cache:
if event.self_id == event.self_id and event.time == event.time:
return
self.on_websocket_connection_event_cache.append(event)
await self.logger.info(f'WebSocket connection established, bot id: {event.self_id}')
self.bot.on_websocket_connection(on_websocket_connection)
def unregister_listener(
self,
event_type: typing.Type[platform_events.Event],
@@ -558,7 +678,12 @@ class AiocqhttpAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None
],
):
return super().unregister_listener(event_type, callback)
listener = self._listener_wrappers.pop((event_type, callback), None)
if listener is None:
return
event_name, wrapper = listener
self.bot._bus.unsubscribe(event_name, wrapper)
async def run_async(self):
await self.bot._server_app.run_task(**self.config)
File diff suppressed because it is too large Load Diff
@@ -103,6 +103,41 @@ spec:
type: string
required: true
default: "填写你的卡片template_id"
- name: human_input_card_template_download
label:
en_US: Download Human Input Card Template
zh_Hans: 下载人工输入卡片模板
zh_Hant: 下載人工輸入卡片範本
description:
en_US: "Used as the only card template ID for the whole conversation turn. Download the built-in template, then import the JSON in DingTalk Open Platform > Card Platform / Card Template Management. After DingTalk creates the template, copy its template ID into the field below. The template already wires `content` (MarkdownBlock) and `btns` (ButtonGroup). Leave empty to fall back to the legacy two-card behavior."
zh_Hans: "用作整个对话回合唯一卡片的模板 ID。先下载内置模板,再到钉钉开放平台 > 卡片平台 / 卡片模板管理中导入该 JSON;钉钉生成模板后,将模板 ID 填到这里。模板已预先连好 `content` (MarkdownBlock) 与 `btns` (ButtonGroup)。留空则降级为旧的双卡行为。"
zh_Hant: "用作整個對話回合唯一卡片的範本 ID。先下載內建範本,再到釘釘開放平台 > 卡片平台 / 卡片範本管理中匯入該 JSON;釘釘產生範本後,將範本 ID 填到這裡。範本已預先連好 `content` (MarkdownBlock) 與 `btns` (ButtonGroup)。留空則降級為舊的雙卡行為。"
type: download-link
required: false
default: ""
url: /api/v1/platform/adapters/dingtalk/human-input-card-template
download_filename: dingtalk_human_input_card.json
help_links:
zh: https://open-dev.dingtalk.com/fe/card
en: https://open-dev.dingtalk.com/fe/card
ja: https://open-dev.dingtalk.com/fe/card
help_label:
en_US: Import Guide
zh_Hans: 导入指引
zh_Hant: 匯入指引
ja_JP: インポート手順
- name: human_input_card_template_id
label:
en_US: Human Input Card Template ID
zh_Hans: 人工输入卡片模板ID
zh_Hant: 人工輸入卡片範本ID
description:
en_US: "Paste the template ID generated after importing the human input card template."
zh_Hans: "填写导入人工输入卡片模板后生成的模板 ID。"
zh_Hant: "填寫匯入人工輸入卡片範本後產生的範本 ID。"
type: string
required: false
default: ""
execution:
python:
path: ./dingtalk.py
+518 -5
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
import discord
from discord import ui as discord_ui
import typing
import re
@@ -8,6 +9,8 @@ import base64
import uuid
import os
import datetime
import time
import traceback
# 使用BytesIO创建文件对象,避免路径问题
import io
@@ -824,6 +827,69 @@ class DiscordEventConverter(abstract_platform_adapter.AbstractEventConverter):
)
class DiscordFormView(discord_ui.View):
"""Discord ``ui.View`` that renders one button per Dify form action.
Each button's click triggers ``adapter._on_form_button_click`` which
acks the interaction, locks the buttons in place, and enqueues a
synthetic ``_dify_form_action`` query so the runner resumes the
workflow.
"""
# Discord button style mapping for Dify ``button_style`` values.
_STYLE_MAP: typing.ClassVar[dict] = {
'primary': discord.ButtonStyle.primary,
'danger': discord.ButtonStyle.danger,
'warning': discord.ButtonStyle.danger,
'success': discord.ButtonStyle.success,
'default': discord.ButtonStyle.secondary,
'': discord.ButtonStyle.secondary,
}
def __init__(
self,
adapter: 'DiscordAdapter',
session_key: str,
actions: list,
timeout: float = 1800,
):
super().__init__(timeout=timeout)
self._adapter = adapter
self._session_key = session_key
# Discord caps a view at 25 children (5 rows × 5 buttons). Trim
# silently — most Dify forms have ≤10 actions in practice.
for idx, action in enumerate(actions[:25]):
action_id = str(action.get('id') or '')
label = str(action.get('title') or action_id or f'Option {idx + 1}')
style = self._STYLE_MAP.get(
str(action.get('button_style') or '').lower(),
discord.ButtonStyle.secondary,
)
# custom_id must be unique within the view and ≤100 chars.
# Encode (session, idx) so we can recover the action even
# if Dify ids contain unsafe characters.
custom_id = f'lb_form:{idx}:{action_id[:80]}'[:100]
button = discord_ui.Button(
label=label[:80], # Discord label limit
style=style,
custom_id=custom_id,
)
button.callback = self._make_callback(action_id, label)
self.add_item(button)
def _make_callback(self, action_id: str, action_title: str):
async def _cb(interaction: discord.Interaction):
await self._adapter._on_form_button_click(
interaction=interaction,
session_key=self._session_key,
action_id=action_id,
action_title=action_title,
view=self,
)
return _cb
class DiscordAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
bot: discord.Client = pydantic.Field(exclude=True)
@@ -837,6 +903,10 @@ class DiscordAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
voice_manager: VoiceConnectionManager | None = pydantic.Field(exclude=True, default=None)
# Injected by botmgr at construction so the form-button callback can
# enqueue a synthetic resume query (`_dify_form_action`) on the pool.
ap: typing.Any = pydantic.Field(exclude=True, default=None)
def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger, **kwargs):
bot_account_id = config['client_id']
@@ -860,8 +930,18 @@ class DiscordAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
args = {}
if os.getenv('http_proxy'):
args['proxy'] = os.getenv('http_proxy')
# Proxy: config > env var > auto-detect.
# discord.py uses aiohttp which does NOT respect http_proxy env
# vars by default — we must pass proxy= explicitly.
proxy = (
config.get('proxy')
or os.getenv('http_proxy')
or os.getenv('HTTP_PROXY')
or os.getenv('https_proxy')
or os.getenv('HTTPS_PROXY')
)
if proxy:
args['proxy'] = proxy
bot = MyClient(intents=intents, **args)
@@ -875,6 +955,19 @@ class DiscordAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
**kwargs,
)
# Per-resp-message-id buffer for the accumulated text yielded by
# the runner. Discord's edit-message ratelimit (5/5s) makes true
# progressive streaming impractical, so we collect chunks and
# render once on is_final. ``_form_data`` on the final chunk
# diverts to the button-view path.
self._stream_buffer: dict[str, str] = {}
# session_key -> {form_data, channel_id, thread_id, sender_id,
# posted_at, view_message_id}
# Populated when we send a form view; consumed when the user
# clicks a button so we know which workflow_run / form_token to
# resume.
self._pending_forms: dict[str, dict] = {}
# Voice functionality methods
async def join_voice_channel(self, guild_id: int, channel_id: int, user_id: int = None) -> discord.VoiceClient:
"""
@@ -1068,7 +1161,12 @@ class DiscordAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
):
msg_to_send, files = await self.message_converter.yiri2target(message)
assert isinstance(message_source.source_platform_object, discord.Message)
# Synthetic events (button-click resume) have no inbound discord
# Message. Route via the channel we cached when the user clicked.
source = message_source.source_platform_object
if not isinstance(source, discord.Message):
await self._reply_synthetic(message_source, msg_to_send, files)
return
args = {
'content': msg_to_send,
@@ -1078,7 +1176,7 @@ class DiscordAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
args['files'] = files
if quote_origin:
args['reference'] = message_source.source_platform_object
args['reference'] = source
has_at = False
@@ -1090,7 +1188,422 @@ class DiscordAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
if has_at:
args['mention_author'] = True
await message_source.source_platform_object.channel.send(**args)
await source.channel.send(**args)
async def _reply_synthetic(
self,
message_source: platform_events.MessageEvent,
msg_to_send: str,
files: list,
) -> None:
"""Deliver a reply for a button-click-resumed (synthetic) event.
We don't have an inbound discord.Message to anchor to; instead
look up the channel cached in ``_pending_forms[session_key +
'__last_channel']`` from the most recent button click.
"""
if isinstance(message_source, platform_events.GroupMessage):
# _handle_form_chunk uses channel_id alone as the session
# scope, and launcher_id was set to channel_id when
# synthesizing the event.
session_key = f'c:{message_source.group.id}'
else:
session_key = f'p:{message_source.sender.id}'
cached = self._pending_forms.get(session_key + '__last_channel') or {}
channel = cached.get('channel')
if channel is None:
if self.ap is not None:
self.ap.logger.warning(
f'Discord: synthetic reply has no cached channel for '
f'{session_key}; dropping content (len={len(msg_to_send)})'
)
return
args: dict[str, typing.Any] = {'content': msg_to_send}
if files:
args['files'] = files
try:
await channel.send(**args)
except Exception:
if self.ap is not None:
self.ap.logger.error(f'Discord: synthetic reply send failed: {traceback.format_exc()}')
# Discord allows 5 edits per 5 seconds per message. We throttle
# to one edit per 8 runner-chunks (runner already yields every 8
# text_chunks internally), which stays comfortably within limits.
_STREAM_EDIT_INTERVAL = 8
async def is_stream_output_supported(self) -> bool:
return True
async def create_message_card(self, message_id: str, event: platform_events.MessageEvent) -> bool:
"""Set up a stream context for progressive editing.
The first non-empty reply_message_chunk will send the initial
message; subsequent chunks edit it in place.
"""
source = event.source_platform_object
if not isinstance(source, discord.Message):
return False
self._stream_buffer[message_id] = {
'channel': source.channel,
'sent_message': None, # discord.Message set on first send
'last_content': '',
'chunk_count': 0,
}
return True
async def reply_message_chunk(
self,
message_source: platform_events.MessageEvent,
bot_message: typing.Any,
message: platform_message.MessageChain,
quote_origin: bool = False,
is_final: bool = False,
):
msg_id = (
bot_message.get('resp_message_id')
if isinstance(bot_message, dict)
else getattr(bot_message, 'resp_message_id', None)
)
text_parts = [m.text for m in message if isinstance(m, platform_message.Plain)]
chunk_text = '\n\n'.join(t for t in text_parts if t)
form_data = getattr(bot_message, '_form_data', None) if not isinstance(bot_message, dict) else None
ctx = self._stream_buffer.get(msg_id) if msg_id else None
# If the stream ctx was not set up (create_message_card wasn't
# called, e.g. synthetic event), or the final chunk carries a
# form, skip progressive editing entirely.
if ctx is None or form_data:
try:
if form_data and is_final:
await self._handle_form_chunk(message_source, form_data)
elif is_final and chunk_text:
await self.reply_message(
message_source,
platform_message.MessageChain([platform_message.Plain(text=chunk_text)]),
quote_origin,
)
finally:
self._stream_buffer.pop(msg_id, None)
return
# Progressive streaming path: send first chunk, edit subsequent.
ctx['chunk_count'] += 1
# Runner yields the full accumulated text on each chunk, so we
# always replace (not append).
if chunk_text:
ctx['last_content'] = chunk_text
sent = ctx['sent_message']
if sent is None:
# First non-empty chunk — send the initial message.
if not ctx['last_content']:
return # No content yet, wait for next chunk.
try:
sent = await ctx['channel'].send(ctx['last_content'])
ctx['sent_message'] = sent
except Exception:
if self.ap is not None:
self.ap.logger.error(f'Discord stream send failed: {traceback.format_exc()}')
self._stream_buffer.pop(msg_id, None)
return
if is_final:
# Final chunk — edit to the full content, then clean up.
if ctx['last_content'] and ctx['last_content'] != sent.content:
try:
await sent.edit(content=ctx['last_content'][:2000])
except Exception:
pass # Best-effort
self._stream_buffer.pop(msg_id, None)
elif (ctx['chunk_count'] % self._STREAM_EDIT_INTERVAL) == 0:
# Intermediate edit — throttle to avoid rate limits.
if ctx['last_content'] and ctx['last_content'] != sent.content:
try:
await sent.edit(content=ctx['last_content'][:2000])
except Exception:
pass # Rate-limited or deleted — ignore.
async def _handle_form_chunk(
self,
message_source: platform_events.MessageEvent,
form_data: dict,
) -> None:
"""Render a Dify form pause as a Discord embed + button View.
Mirrors the QQ / Telegram / Lark form path: the button's click
callback synthesizes a ``_dify_form_action`` query so the runner's
``_merge_pending_form_action`` resumes the workflow.
"""
source = message_source.source_platform_object
actions = form_data.get('actions') or []
if not actions:
# Nothing clickable — fall back to plain text.
if source is not None:
await self.reply_message(
message_source,
platform_message.MessageChain(
[platform_message.Plain(text=str(form_data.get('node_title') or ''))]
),
)
return
node_title = str(form_data.get('node_title') or 'Confirmation needed')
form_content = str(form_data.get('form_content') or '').strip()
# Two paths:
# (a) Real message — extract channel from source.
# (b) Synthetic event (button-click resume) — no
# source_platform_object; recover the channel we cached
# when the user clicked.
if isinstance(source, discord.Message):
channel = source.channel
guild_id = str(source.guild.id) if source.guild else ''
sender_id = str(source.author.id)
channel_id = str(source.channel.id)
session_key = f'c:{channel_id}' if guild_id else f'p:{sender_id}'
else:
# Synthetic event — resolve session_key from event shape,
# then look up the cached channel from the click.
if isinstance(message_source, platform_events.GroupMessage):
# launcher_id was set to channel_id when we synthesized.
channel_id = str(message_source.group.id)
session_key = f'c:{channel_id}'
else:
session_key = f'p:{message_source.sender.id}'
channel_id = ''
cached = self._pending_forms.get(session_key + '__last_channel')
channel = cached.get('channel') if cached else None
guild_id = (cached or {}).get('guild_id', '')
sender_id = str(message_source.sender.id) if message_source.sender else ''
if channel is None:
if self.ap is not None:
self.ap.logger.warning(
f'Discord: synthetic form chunk has no cached channel for '
f'{session_key}; cannot render form buttons'
)
return
body_parts: list[str] = []
if form_content:
body_parts.append(form_content)
embed_body = '\n\n'.join(body_parts)
# Discord embed.description has a 4096 char limit — defensive trim.
if len(embed_body) > 4000:
embed_body = embed_body[:3990] + '\n\n…(truncated)'
embed = discord.Embed(
title=node_title[:256],
description=embed_body,
color=discord.Color.blurple(),
)
view = DiscordFormView(
adapter=self,
session_key=session_key,
actions=actions,
timeout=1800, # 30 min — matches Dify form_token TTL
)
try:
sent_msg = await channel.send(embed=embed, view=view)
except Exception:
if self.ap is not None:
self.ap.logger.error(f'Discord: form view send failed: {traceback.format_exc()}')
return
self._pending_forms[session_key] = {
'form_data': form_data,
'channel_id': channel_id,
'guild_id': guild_id,
'sender_id': sender_id,
'view_message_id': str(sent_msg.id),
'posted_at': time.time(),
}
if self.ap is not None:
self.ap.logger.info(f'Discord: form view posted session={session_key} actions={len(actions)}')
async def _on_form_button_click(
self,
interaction: discord.Interaction,
session_key: str,
action_id: str,
action_title: str,
view: DiscordFormView,
) -> None:
"""Handle a click on a form button — ack, resume the workflow,
and disable the View buttons so the choice is visually locked in."""
import langbot_plugin.api.entities.builtin.provider.session as provider_session
# ACK first (3-second deadline before Discord shows "interaction failed").
try:
await interaction.response.defer()
except discord.HTTPException:
# Already responded somehow — proceed regardless.
pass
pending = self._pending_forms.get(session_key)
if not pending:
if self.ap is not None:
self.ap.logger.warning(
f'Discord: button click on stale session {session_key}; ignoring (action_id={action_id!r})'
)
await self._lock_view_message(interaction, view, action_title, stale=True)
return
form_data: dict = pending.get('form_data') or {}
guild_id = pending.get('guild_id', '')
channel_id = pending.get('channel_id', '')
initiator_id = str(pending.get('sender_id', '') or '')
actor_id = str(interaction.user.id) if interaction.user is not None else initiator_id
if not guild_id and initiator_id and actor_id != initiator_id:
if self.ap is not None:
self.ap.logger.warning(
f'Discord: user {actor_id} cannot act on private form created for {initiator_id}'
)
await self._lock_view_message(interaction, view, action_title, stale=True)
return
self._pending_forms.pop(session_key, None)
# Lock the buttons in place: disable everything, mark chosen one.
await self._lock_view_message(interaction, view, action_title)
# In group context the launcher remains the channel so Dify resumes
# the original group session. The synthetic sender is still the real
# clicker, preserving actor identity for auditing and routing rules.
if guild_id:
launcher_type = provider_session.LauncherTypes.GROUP
launcher_id = channel_id
else:
launcher_type = provider_session.LauncherTypes.PERSON
launcher_id = initiator_id or actor_id
form_action_data = {
'form_token': form_data.get('form_token', ''),
'workflow_run_id': form_data.get('workflow_run_id', ''),
'action_id': action_id,
'action_title': action_title,
'node_title': form_data.get('node_title', ''),
'user': f'{launcher_type.value}_{launcher_id}',
'inputs': {},
}
message_chain = platform_message.MessageChain([platform_message.Plain(text=f'[Form Action: {action_title}]')])
# Synthesize a platform event so the pipeline can run the resume
# query. source_platform_object=None signals "no inbound discord
# message" — reply_message must tolerate this (it falls through
# to channel.send via the cached interaction.channel below).
if launcher_type == provider_session.LauncherTypes.GROUP:
synthetic_event: platform_events.MessageEvent = platform_events.GroupMessage(
sender=platform_entities.GroupMember(
id=actor_id,
member_name=interaction.user.display_name if interaction.user else '',
permission='MEMBER',
group=platform_entities.Group(
id=launcher_id,
name=channel_id,
permission=platform_entities.Permission.Member,
),
special_title='',
),
message_chain=message_chain,
time=int(time.time()),
source_platform_object=None,
)
else:
synthetic_event = platform_events.FriendMessage(
sender=platform_entities.Friend(
id=actor_id,
nickname=interaction.user.display_name if interaction.user else '',
remark='',
),
message_chain=message_chain,
time=int(time.time()),
source_platform_object=None,
)
if self.ap is None:
if self.logger:
await self.logger.error('Discord: ap not injected; cannot enqueue button-click query')
return
bot_uuid = ''
pipeline_uuid = form_data.get('pipeline_uuid') or None
for bot in self.ap.platform_mgr.bots:
if bot.adapter is self:
bot_uuid = bot.bot_entity.uuid
pipeline_uuid = pipeline_uuid or bot.bot_entity.use_pipeline_uuid
break
# Remember the channel so _reply_synthetic and _handle_form_chunk
# (synthetic-event path) can find a target. guild_id is needed
# to reconstruct the launcher_type on subsequent form pauses.
self._pending_forms[session_key + '__last_channel'] = {
'channel': interaction.channel,
'guild_id': guild_id,
'posted_at': time.time(),
}
try:
await self.ap.query_pool.add_query(
bot_uuid=bot_uuid,
launcher_type=launcher_type,
launcher_id=launcher_id,
sender_id=actor_id,
message_event=synthetic_event,
message_chain=message_chain,
adapter=self,
pipeline_uuid=pipeline_uuid,
variables={
'_dify_form_action': form_action_data,
'_routed_by_rule': True,
},
)
if self.ap is not None:
self.ap.logger.info(
f'Discord: button-click query enqueued action_id={action_id!r} '
f'session={session_key} actor_id={actor_id}'
)
except Exception:
if self.ap is not None:
self.ap.logger.error(f'Discord: enqueue button-click query failed: {traceback.format_exc()}')
async def _lock_view_message(
self,
interaction: discord.Interaction,
view: DiscordFormView,
chosen_title: str,
stale: bool = False,
) -> None:
"""Disable all buttons on the form view and annotate the chosen
one mirrors DingTalk/Lark's in-card selection feedback."""
try:
for child in view.children:
if not isinstance(child, discord_ui.Button):
continue
child.disabled = True
if not stale and child.label == chosen_title:
child.style = discord.ButtonStyle.success
if not (child.label or '').startswith(''):
child.label = f'{child.label}'
view.stop()
if interaction.message is not None:
await interaction.message.edit(view=view)
except Exception:
if self.ap is not None:
self.ap.logger.warning(f'Discord: lock-view-message failed (non-fatal): {traceback.format_exc()}')
async def is_muted(self, group_id: int) -> bool:
return False
File diff suppressed because it is too large Load Diff
+582 -3
View File
@@ -11,7 +11,13 @@ import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platf
import langbot_plugin.api.entities.builtin.platform.message as platform_message
import langbot_plugin.api.entities.builtin.platform.events as platform_events
import langbot_plugin.api.entities.builtin.platform.entities as platform_entities
from langbot.libs.qq_official_api.api import QQOfficialClient
from langbot.libs.qq_official_api.api import (
QQ_SELECT_ACTION_PREFIX,
QQOfficialClient,
build_keyboard_from_form,
build_keyboard_from_select_field,
resolve_select_button_action,
)
from langbot.libs.qq_official_api.qqofficialevent import QQOfficialEvent
from ...utils import image
from ..logger import EventLogger
@@ -191,6 +197,7 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
enable_webhook: bool = False
message_converter: QQOfficialMessageConverter = QQOfficialMessageConverter()
event_converter: QQOfficialEventConverter = QQOfficialEventConverter()
ap: typing.Any = None
def __init__(self, config: dict, logger: EventLogger):
enable_webhook = config.get('enable-webhook', False)
@@ -216,6 +223,31 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
self._stream_ctx_ts: dict[str, float] = {}
self._fallback_text: dict[str, str] = {}
self._fallback_text_ts: dict[str, float] = {}
# Dify form-action bookkeeping for the human-input button flow.
# session_key = "<scene>_<id>" where scene is c2c/group/channel and
# id is user_openid / group_openid / channel_id.
# session_key -> {form_data, msg_id, event_id, scene, target_id,
# sender_id, posted_at}
# Set when we send a markdown+keyboard card and consulted when:
# (a) INTERACTION_CREATE fires — we look up the form by
# session_key (button's `data` carries the action_id),
# (b) the resumed-workflow query needs to find a passive-reply
# event_id (INTERACTION_CREATE id, 30-min validity).
self._pending_forms: dict[str, dict] = {}
# session_key -> most recent ``INTERACTION_CREATE`` event_id, used
# as the passive event_id for the resumed query's LLM output.
self._session_event_ids: dict[str, dict] = {}
# Per-anchor msg_seq counter. QQ accepts up to 5 passive replies
# per (msg_id|event_id) within 60 min, but each reuse needs a
# fresh ``msg_seq`` — re-sending with msg_seq=1 is silently dedup'd.
self._anchor_msg_seq: dict[str, int] = {}
# Wire button-click handler so webhook mode catches INTERACTION_CREATE.
# (ws mode is wired separately via on_event in _run_websocket so the
# raw payload bypasses get_message's message-only flattening.)
@self.bot.on_interaction()
async def _on_interaction(event_data: dict, interaction_id: typing.Optional[str]):
await self._handle_interaction_create(event_data, interaction_id)
async def reply_message(
self,
@@ -227,6 +259,13 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
message_source,
)
# Synthetic event (button-click resume): no inbound platform
# object → no msg_id. Route via the cached INTERACTION_CREATE
# event_id (valid 30 min, no quota cost).
if qq_official_event is None:
await self._reply_synthetic(message_source, message)
return
content_list = await QQOfficialMessageConverter.yiri2target(message)
# 确定 target_type 和 target_id
@@ -376,6 +415,9 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
await self.logger.info('QQ Official WebSocket connected and ready')
async def on_event(event_type: str, event_data: dict):
# INTERACTION_CREATE is dispatched via bot.on_interaction()
# (registered in __init__) so we get the top-level ws_event_id
# — needed as the passive-reply event_id. It never reaches here.
# 只处理消息事件,忽略 READY/RESUMED 等系统事件
message_event_types = {
'C2C_MESSAGE_CREATE',
@@ -437,12 +479,36 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
async def is_stream_output_supported(self) -> bool:
return self.config.get('enable-stream-reply', False)
@staticmethod
def _is_form_placeholder_chunk(text: str) -> bool:
"""Return True for invisible placeholder chunks used to carry forms."""
if not text:
return False
cleaned = text.replace('\u200b', '').replace('\u200c', '').replace('\u200d', '').replace('\ufeff', '').strip()
# Some Windows consoles/logs display the zero-width placeholder as
# mojibake. Treat those variants as the same non-user-facing marker.
return cleaned in {'', '鈥?', '​'}
async def create_message_card(self, message_id: str, event: platform_events.MessageEvent) -> bool:
source = event.source_platform_object
# Synthetic events (button-click resume) have no source object —
# they ride a cached INTERACTION_CREATE event_id, not a streamable
# msg_id. Skip stream setup; reply_message handles the one-shot
# send at is_final.
if source is None:
return False
# Streaming API only supports C2C private chat
if source.t != 'C2C_MESSAGE_CREATE':
return False
# The stream endpoint still consumes msg_seq for this inbound msg_id.
# Keep the passive-reply counter in sync so a follow-up form card uses
# msg_seq=2 instead of being deduplicated by QQ as another seq=1 send.
if source.d_id:
self._anchor_msg_seq[source.d_id] = max(self._anchor_msg_seq.get(source.d_id, 0), 1)
ctx = {
'user_openid': source.user_openid,
'msg_id': source.d_id,
@@ -469,12 +535,38 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
):
# Periodically clean up stale stream contexts
await self._cleanup_stale_streams()
# Dify human-input pause: when the runner attaches `_form_data` to
# the final chunk, finalize any in-flight stream session and send
# a markdown + keyboard message instead. Plain-text content from
# earlier chunks is already on the stream; we close it cleanly
# and the buttons land as a separate reply.
form_data = getattr(bot_message, '_form_data', None) if not isinstance(bot_message, dict) else None
if is_final:
_resume = getattr(bot_message, '_resume_from_form', None) if not isinstance(bot_message, dict) else None
_open_new = getattr(bot_message, '_open_new_card', None) if not isinstance(bot_message, dict) else None
if self.ap is not None:
self.ap.logger.info(
f'QQ Official reply_message_chunk final: '
f'type={type(bot_message).__name__} '
f'is_final={is_final} '
f'form_data_present={form_data is not None} '
f'resume_from_form={_resume} open_new_card={_open_new} '
f'content_len={len(getattr(bot_message, "content", "") or "")}'
)
if form_data and is_final:
await self._handle_form_chunk(message_source, message, form_data)
return
# 提取纯文本内容(当前 chunk 的文本)
text_parts = []
for msg in message:
if type(msg) is platform_message.Plain:
text_parts.append(msg.text)
chunk_text = '\n\n'.join(text_parts)
if self._is_form_placeholder_chunk(chunk_text):
await self.logger.debug('QQ Official: skipped invisible form placeholder chunk')
return
message_id = (
bot_message.get('resp_message_id')
@@ -484,7 +576,8 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
if not message_id or message_id not in self._stream_ctx:
# 非流式场景(如群聊不支持流式),累积文本后一次性回复
if chunk_text:
self._fallback_text[message_id] = self._fallback_text.get(message_id, '') + chunk_text
# Chunks carry the latest full snapshot, not a text delta.
self._fallback_text[message_id] = chunk_text
self._fallback_text_ts[message_id] = time.time()
if is_final:
full_text = self._fallback_text.pop(message_id, '')
@@ -497,7 +590,7 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
# 累积文本
if chunk_text:
ctx['accumulated_text'] += chunk_text
ctx['accumulated_text'] = chunk_text
# 未启动会话时,等第一个有内容的 chunk 来建立会话
if not ctx['session_started']:
@@ -557,3 +650,489 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
],
):
return super().unregister_listener(event_type, callback)
# ------------------------------------------------------------------
# Dify human-input button-interaction support
# ------------------------------------------------------------------
_PENDING_FORM_TTL = 1800 # 30 min — matches QQ passive-reply window.
_MAX_REPLIES_PER_ANCHOR = 5 # QQ hard limit per msg_id / event_id.
def _next_msg_seq(self, anchor: str) -> typing.Optional[int]:
"""Return the next msg_seq for an anchor, or ``None`` if the
anchor has already been used 5 times (further sends would be
silently dropped by QQ)."""
if not anchor:
return 1
used = self._anchor_msg_seq.get(anchor, 0)
if used >= self._MAX_REPLIES_PER_ANCHOR:
return None
self._anchor_msg_seq[anchor] = used + 1
return used + 1
async def _reply_synthetic(
self,
message_source: platform_events.MessageEvent,
message: platform_message.MessageChain,
) -> None:
"""Deliver a reply for a synthetic (button-click-resume) event.
Synthetic events have ``source_platform_object=None`` and no
fresh inbound msg_id. The previous INTERACTION_CREATE id we
cached in :attr:`_session_event_ids` is a valid passive-reply
anchor (``event_id``) for up to 30 minutes use it.
"""
if isinstance(message_source, platform_events.GroupMessage):
target_type = 'group'
group = getattr(message_source, 'group', None) or (
message_source.sender.group if hasattr(message_source.sender, 'group') else None
)
target_id = str(group.id) if group else None
else:
target_type = 'c2c'
target_id = str(message_source.sender.id) if message_source.sender else None
if not target_id:
await self.logger.warning('QQ Official: synthetic reply has no target_id; dropping')
return
session_key = f'{target_type}_{target_id}'
cached = self._session_event_ids.get(session_key)
event_id = cached.get('event_id') if cached else None
if cached and (time.time() - cached.get('posted_at', 0)) > self._PENDING_FORM_TTL:
event_id = None
if not event_id:
await self.logger.warning(
f'QQ Official: no cached event_id for {session_key}; '
f'cannot deliver synthetic reply within passive-reply window'
)
return
content_list = await QQOfficialMessageConverter.yiri2target(message)
text_parts = [c['content'] for c in content_list if c.get('type') == 'text' and c.get('content')]
if not text_parts:
await self.logger.info('QQ Official: synthetic reply has no text content; skipping')
return
text = '\n\n'.join(text_parts)
msg_seq = self._next_msg_seq(event_id)
if msg_seq is None:
await self.logger.warning(
f'QQ Official: anchor {event_id!r} exhausted (>5 passive replies); '
f'cannot deliver synthetic reply for {session_key}'
)
return
try:
if target_type == 'c2c':
await self.bot.send_private_text_msg(
user_openid=target_id,
content=text,
event_id=event_id,
msg_seq=msg_seq,
)
elif target_type == 'group':
await self.bot.send_group_text_msg(
group_openid=target_id,
content=text,
event_id=event_id,
msg_seq=msg_seq,
)
except Exception:
await self.logger.error(f'QQ Official: synthetic reply delivery failed: {traceback.format_exc()}')
def _resolve_target_from_source(self, source: QQOfficialEvent) -> typing.Optional[tuple[str, str]]:
"""Return ``(target_type, target_id)`` for sending a reply, or
``None`` if the scene cannot host a markdown+keyboard message."""
if source is None:
return None
if source.t == 'C2C_MESSAGE_CREATE':
return 'c2c', source.user_openid
if source.t == 'GROUP_AT_MESSAGE_CREATE':
return 'group', source.group_openid
if source.t == 'AT_MESSAGE_CREATE':
return 'channel', source.channel_id
# DIRECT_MESSAGE_CREATE uses the guild DM API which does not accept
# markdown+keyboard at the time of writing — caller falls back to text.
return None
def _resolve_target_from_event(
self, message_source: platform_events.MessageEvent
) -> typing.Optional[tuple[str, str]]:
"""Resolve ``(target_type, target_id)`` from the public event.
Prefers the platform-native source when present; falls back to
the synthesized event's sender/group fields so button-click
resume queries can still find a destination.
"""
source = message_source.source_platform_object
if source is not None:
return self._resolve_target_from_source(source)
if isinstance(message_source, platform_events.GroupMessage):
group = getattr(message_source, 'group', None) or (
message_source.sender.group
if message_source.sender and hasattr(message_source.sender, 'group')
else None
)
if group and getattr(group, 'id', None):
return 'group', str(group.id)
if isinstance(message_source, platform_events.FriendMessage):
if message_source.sender and getattr(message_source.sender, 'id', None):
return 'c2c', str(message_source.sender.id)
return None
def _prune_pending_forms(self) -> None:
now = time.time()
stale = [k for k, v in self._pending_forms.items() if now - v.get('posted_at', 0) > self._PENDING_FORM_TTL]
for k in stale:
self._pending_forms.pop(k, None)
stale_e = [
k for k, v in self._session_event_ids.items() if now - v.get('posted_at', 0) > self._PENDING_FORM_TTL
]
for k in stale_e:
self._session_event_ids.pop(k, None)
async def _handle_form_chunk(
self,
message_source: platform_events.MessageEvent,
message: platform_message.MessageChain,
form_data: dict,
) -> None:
"""Send the markdown + keyboard form prompt for a Dify pause.
Called from ``reply_message_chunk`` when the runner attaches
``_form_data`` to the final chunk. Replaces what would otherwise
be a plain-text numbered-list fallback.
"""
if self.ap is not None:
self.ap.logger.info(
f'QQ Official _handle_form_chunk entered; '
f'source_present={message_source.source_platform_object is not None} '
f'form_actions={len(form_data.get("actions") or [])}'
)
self._prune_pending_forms()
source = message_source.source_platform_object
scene_target = self._resolve_target_from_event(message_source)
if scene_target is None:
# No rich-UI fit — fall through to existing text path.
await self.logger.info('QQ Official: form chunk on unsupported scene; falling back to text')
text_parts = [m.text for m in message if type(m) is platform_message.Plain]
fallback_msg = platform_message.MessageChain([platform_message.Plain(text='\n\n'.join(text_parts))])
try:
await self.reply_message(message_source, fallback_msg)
except Exception:
await self.logger.error(f'QQ Official: form fallback text send failed: {traceback.format_exc()}')
return
target_type, target_id = scene_target
session_key = f'{target_type}_{target_id}'
# Cancel any in-flight stream / fallback ctx so plain-text prefix
# doesn't continue alongside the keyboard message.
msg_id = getattr(source, 'd_id', '') or '' if source is not None else ''
if msg_id:
self._stream_ctx.pop(msg_id, None)
self._stream_ctx_ts.pop(msg_id, None)
self._fallback_text.pop(msg_id, None)
self._fallback_text_ts.pop(msg_id, None)
node_title = form_data.get('node_title') or 'Confirmation needed'
form_content = form_data.get('form_content') or ''
is_field_step = bool(form_data.get('_current_input_field')) and not form_data.get('_action_select_only')
parts = [f'### {node_title}']
plain_parts = [node_title]
if form_content.strip():
parts.append(form_content.strip())
plain_parts.append(form_content.strip())
markdown_content = '\n\n'.join(parts)
plain_content = '\n\n'.join(plain_parts)
keyboard = build_keyboard_from_select_field(form_data) if is_field_step else None
is_text_field_step = is_field_step and not keyboard.get('content', {}).get('rows')
if is_text_field_step:
keyboard = None
if keyboard is None and not is_text_field_step:
keyboard = build_keyboard_from_form(form_data, buttons_per_row=2)
if keyboard is not None and not keyboard.get('content', {}).get('rows') and not is_text_field_step:
# No actions to render — fall back to plain text.
text_msg = platform_message.MessageChain([platform_message.Plain(text=plain_content)])
try:
await self.reply_message(message_source, text_msg)
except Exception:
await self.logger.error(f'QQ Official: empty-keyboard fallback send failed: {traceback.format_exc()}')
return
# Prefer the inbound msg_id (no quota cost). If the source is a
# synthetic event from a prior click, the cached interaction id
# serves as event_id for up to 30 min.
event_id = None
if not msg_id:
cached = self._session_event_ids.get(session_key)
if cached and (time.time() - cached.get('posted_at', 0)) < self._PENDING_FORM_TTL:
event_id = cached.get('event_id')
anchor = msg_id or event_id or ''
msg_seq = self._next_msg_seq(anchor)
if msg_seq is None:
await self.logger.warning(
f'QQ Official: anchor {anchor!r} exhausted (>5 passive replies); '
f'cannot deliver form card for session={session_key}'
)
return
try:
await self.bot.send_markdown_keyboard(
target_type=target_type,
target_id=target_id,
markdown_content=markdown_content,
keyboard=keyboard,
msg_id=msg_id if (msg_id and not event_id) else None,
event_id=event_id,
msg_seq=msg_seq,
)
if self.ap is not None:
self.ap.logger.info(
f'QQ Official: form card sent '
f'target={target_type}/{target_id} '
f'msg_id={msg_id!r} event_id={event_id!r} msg_seq={msg_seq}'
)
except Exception:
if self.ap is not None:
self.ap.logger.error(
f'QQ Official: send_markdown_keyboard failed, falling back to text: {traceback.format_exc()}'
)
await self.logger.error(
f'QQ Official: send_markdown_keyboard failed, falling back to text: {traceback.format_exc()}'
)
text_msg = platform_message.MessageChain([platform_message.Plain(text=plain_content)])
try:
await self.reply_message(message_source, text_msg)
except Exception:
pass
return
sender_id = ''
if source is not None:
sender_id = (
getattr(source, 'user_openid', None)
or getattr(source, 'member_openid', None)
or getattr(source, 'd_author_id', None)
or ''
)
if not sender_id and message_source.sender is not None:
sender_id = str(getattr(message_source.sender, 'id', '') or '')
self._pending_forms[session_key] = {
'form_data': form_data,
'msg_id': msg_id,
'sender_id': sender_id,
'target_type': target_type,
'target_id': target_id,
'source_event_t': source.t if source is not None else None,
'posted_at': time.time(),
}
await self.logger.info(
f'QQ Official: form posted session={session_key} actions={len(form_data.get("actions") or [])}'
)
async def _handle_interaction_create(
self,
event_data: dict,
ws_event_id: typing.Optional[str] = None,
) -> None:
"""Handle a button-click INTERACTION_CREATE event.
Two IDs at play (QQ keeps them separate):
ws_event_id top-level payload ``id`` (or webhook ``X-Bot-
Event-Id``). The ONLY value accepted as
``event_id`` for subsequent passive replies.
d['id'] the interaction id used for PUT
/interactions/{id} ack. Cannot be reused as
event_id (QQ returns 40034025 if you try).
Layout (https://bot.q.qq.com/.../msg-btn.html):
chat_type 0 channel / 1 group / 2 c2c
data.resolved.button_data what we set as ``action.data``
data.resolved.button_id ``id`` field on the button row
"""
import langbot_plugin.api.entities.builtin.provider.session as provider_session
if self.ap is not None:
self.ap.logger.info(
f'QQ Official _handle_interaction_create entered; '
f'ws_event_id={ws_event_id!r} '
f'interaction_id={(event_data.get("id") if isinstance(event_data, dict) else None)!r} '
f'chat_type={event_data.get("chat_type") if isinstance(event_data, dict) else None}'
)
if not isinstance(event_data, dict):
await self.logger.warning(f'QQ Official: INTERACTION_CREATE event_data is not dict: {type(event_data)}')
return
# ACK uses the interaction id, NOT the ws event id.
interaction_id = event_data.get('id') or ''
if interaction_id:
asyncio.create_task(self.bot.ack_interaction(interaction_id, code=0))
resolved = (event_data.get('data') or {}).get('resolved') or {}
action_id = str(resolved.get('button_data') or resolved.get('button_id') or '').strip()
if not action_id:
await self.logger.warning('QQ Official: INTERACTION_CREATE missing button_data/button_id; ignoring')
return
chat_type = event_data.get('chat_type')
scene_target: typing.Optional[tuple[str, str]] = None
if chat_type == 2 or event_data.get('user_openid'):
scene_target = ('c2c', event_data.get('user_openid') or '')
elif chat_type == 1 or event_data.get('group_openid'):
scene_target = ('group', event_data.get('group_openid') or '')
elif chat_type == 0 or event_data.get('channel_id'):
scene_target = ('channel', event_data.get('channel_id') or '')
if not scene_target or not scene_target[1]:
await self.logger.warning(f'QQ Official: INTERACTION_CREATE missing scene/target; raw={event_data}')
return
target_type, target_id = scene_target
session_key = f'{target_type}_{target_id}'
self._prune_pending_forms()
pending = self._pending_forms.get(session_key)
if not pending:
await self.logger.warning(
f'QQ Official: no pending form for session {session_key}; click ignored (action_id={action_id!r})'
)
return
# Cache ws_event_id so a follow-up pause / text reply can use it
# as event_id for passive delivery (30-min window). Falls back to
# the interaction_id only if no ws_event_id was provided (e.g.
# tests / older payload shape) — QQ will reject that value but
# we log so the mismatch is debuggable.
cached_event_id = ws_event_id or interaction_id
if cached_event_id:
self._session_event_ids[session_key] = {
'event_id': cached_event_id,
'posted_at': time.time(),
}
# New anchor → fresh 5-reply budget.
self._anchor_msg_seq[cached_event_id] = 0
if self.ap is not None and not ws_event_id:
self.ap.logger.warning(
'QQ Official: INTERACTION_CREATE lacked ws_event_id; '
'falling back to interaction_id (passive reply may be rejected)'
)
form_data: dict = pending.get('form_data') or {}
actions = form_data.get('actions') or []
select_choice = resolve_select_button_action(form_data, action_id)
if action_id.startswith(QQ_SELECT_ACTION_PREFIX) and select_choice is None:
await self.logger.warning(f'QQ Official: invalid select action_id={action_id!r} for {session_key}')
return
matched = None
if select_choice is None:
matched = next(
(a for a in actions if str(a.get('id', '')) == action_id),
None,
)
if matched is None:
await self.logger.warning(
f'QQ Official: action_id={action_id!r} is not present on pending form for {session_key}'
)
return
self._pending_forms.pop(session_key, None)
action_title = select_choice[1] if select_choice else matched.get('title') or action_id
initiator_id = str(pending.get('sender_id') or '')
actor_id = str(event_data.get('member_openid') or event_data.get('user_openid') or initiator_id)
# Build resume payload matching the shape every other adapter uses
# (DingTalk / Lark / Telegram / WeCom). The runner's
# _merge_pending_form_action consumes this verbatim.
if target_type == 'group' or target_type == 'channel':
launcher_type = provider_session.LauncherTypes.GROUP
launcher_id = target_id
else:
launcher_type = provider_session.LauncherTypes.PERSON
launcher_id = target_id
form_action_data = {
'form_token': form_data.get('form_token', ''),
'workflow_run_id': form_data.get('workflow_run_id', ''),
'action_id': '' if select_choice else action_id,
'action_title': action_title,
'node_title': form_data.get('node_title', ''),
'user': f'{launcher_type.value}_{launcher_id}',
'inputs': {'select': select_choice[1]} if select_choice else {},
}
if select_choice:
form_action_data['_current_input_field'] = select_choice[0]
form_action_data['_input_progress'] = True
event_label = 'Form Select' if select_choice else 'Form Action'
message_chain = platform_message.MessageChain([platform_message.Plain(text=f'[{event_label}: {action_title}]')])
if launcher_type == provider_session.LauncherTypes.GROUP:
synthetic_event: platform_events.MessageEvent = platform_events.GroupMessage(
sender=platform_entities.GroupMember(
id=actor_id or launcher_id,
member_name='',
permission='MEMBER',
group=platform_entities.Group(
id=launcher_id,
name='',
permission=platform_entities.Permission.Member,
),
special_title='',
),
message_chain=message_chain,
time=int(time.time()),
source_platform_object=None,
)
else:
synthetic_event = platform_events.FriendMessage(
sender=platform_entities.Friend(
id=actor_id or launcher_id,
nickname='',
remark='',
),
message_chain=message_chain,
time=int(time.time()),
source_platform_object=None,
)
if self.ap is None:
await self.logger.error('QQ Official: ap not injected; cannot enqueue button-click query')
return
bot_uuid = ''
pipeline_uuid = form_data.get('pipeline_uuid') or None
for bot in self.ap.platform_mgr.bots:
if bot.adapter is self:
bot_uuid = bot.bot_entity.uuid
pipeline_uuid = pipeline_uuid or bot.bot_entity.use_pipeline_uuid
break
try:
await self.ap.query_pool.add_query(
bot_uuid=bot_uuid,
launcher_type=launcher_type,
launcher_id=launcher_id,
sender_id=actor_id or launcher_id,
message_event=synthetic_event,
message_chain=message_chain,
adapter=self,
pipeline_uuid=pipeline_uuid,
variables={
'_dify_form_action': form_action_data,
'_routed_by_rule': True,
},
)
await self.logger.info(
f'QQ Official: button-click query enqueued action_id={action_id!r} '
f'session={session_key} actor_id={actor_id}'
)
except Exception:
await self.logger.error(f'QQ Official: enqueue button-click query failed: {traceback.format_exc()}')
@@ -31,6 +31,18 @@ spec:
type: array[string]
required: false
default: []
- name: one-click-bind
label:
en_US: One-Click QR Binding
zh_Hans: 一键扫码绑定
zh_Hant: 一鍵掃碼綁定
description:
en_US: Scan QR code with mobile QQ to auto-fill AppID and Secret (Token is not used and can be left blank)
zh_Hans: 使用手机 QQ 扫码绑定,自动填写 AppID 和密钥(当前未使用 Token,可留空)
zh_Hant: 使用手機 QQ 掃碼綁定,自動填寫 AppID 和密鑰(目前未使用 Token,可留空)
type: qr-code-login
login_platform: qqofficial
required: false
- name: appid
label:
en_US: App ID
@@ -52,8 +64,12 @@ spec:
en_US: Token
zh_Hans: 令牌
zh_Hant: 令牌
description:
en_US: Optional. The QR binding cannot return this value; the current adapter implementation does not use it either, so it can be safely left blank.
zh_Hans: 可选。扫码绑定无法获取该字段,当前适配器实现也未使用该字段,留空即可。
zh_Hant: 可選。掃碼綁定無法取得此欄位,目前介面卡實作亦未使用,留空即可。
type: string
required: true
required: false
default: ""
- name: enable-webhook
label:
+427 -25
View File
@@ -1,15 +1,17 @@
from __future__ import annotations
import time
import telegram
import telegram.ext
from telegram import Update
from telegram.ext import ApplicationBuilder, ContextTypes, MessageHandler, filters
from telegram import ForceReply, InlineKeyboardButton, InlineKeyboardMarkup, Update
from telegram.ext import ApplicationBuilder, ContextTypes, MessageHandler, CallbackQueryHandler, filters
import telegramify_markdown
import typing
import traceback
import json
import base64
import time
import uuid
import pydantic
from langbot.pkg.utils import httpclient
@@ -20,6 +22,61 @@ import langbot_plugin.api.entities.builtin.platform.entities as platform_entitie
import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger
def _telegram_select_field_options(form_data: dict) -> tuple[str, list[str]]:
"""Return the active select field and its option values."""
field_name = str(form_data.get('_current_input_field') or '').strip()
if not field_name:
return '', []
field = next(
(
item
for item in form_data.get('input_defs') or []
if str(item.get('output_variable_name') or '').strip() == field_name
),
None,
)
if not field or str(field.get('type') or '').strip().lower() != 'select':
return '', []
source = field.get('option_source') or {}
source_value = source.get('value') if isinstance(source, dict) else None
if isinstance(source_value, list):
return field_name, [str(item) for item in source_value]
if isinstance(source_value, str):
return field_name, [part.strip() for part in source_value.splitlines() if part.strip()]
options = field.get('options')
if not isinstance(options, list):
return field_name, []
values = []
for item in options:
if isinstance(item, dict):
values.append(str(item.get('label') or item.get('value') or ''))
else:
values.append(str(item))
return field_name, [value for value in values if value]
def _telegram_form_action_from_callback(data: dict) -> dict | None:
"""Translate compact Telegram callback data into a runner form action."""
if 'x' not in data:
return {
'action_id': str(data.get('action_id') or data.get('a') or ''),
'inputs': {},
}
try:
option_index = int(data['x'])
except (TypeError, ValueError):
return None
if option_index < 0:
return None
return {
'action_id': '',
'inputs': {'select': {'index': option_index}},
'_input_progress': True,
}
class TelegramMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
@staticmethod
async def yiri2target(message_chain: platform_message.MessageChain, bot: telegram.Bot) -> list[dict]:
@@ -167,7 +224,7 @@ class TelegramEventConverter(abstract_platform_adapter.AbstractEventConverter):
time=event.message.date.timestamp(),
source_platform_object=event,
)
elif event.effective_chat.type == 'group' or 'supergroup':
elif event.effective_chat.type in ('group', 'supergroup'):
return platform_events.GroupMessage(
sender=platform_entities.GroupMember(
id=event.effective_chat.id,
@@ -189,6 +246,7 @@ class TelegramEventConverter(abstract_platform_adapter.AbstractEventConverter):
class TelegramAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
bot: telegram.Bot = pydantic.Field(exclude=True)
application: telegram.ext.Application = pydantic.Field(exclude=True)
ap: typing.Any = pydantic.Field(exclude=True, default=None)
message_converter: TelegramMessageConverter = TelegramMessageConverter()
event_converter: TelegramEventConverter = TelegramEventConverter()
@@ -204,6 +262,48 @@ class TelegramAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
typing.Callable[[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None],
] = {}
_FORM_ACTION_CACHE_TTL = 30 * 60
# callback_data -> (display title, pipeline UUID, expiration time, form group id)
_form_action_titles: typing.Dict[str, tuple[str, str, float, str]] = {}
def _prune_form_action_titles(self, now: float | None = None) -> None:
now = time.monotonic() if now is None else now
expired = [key for key, (_, _, expires_at, _) in self._form_action_titles.items() if expires_at <= now]
for key in expired:
self._form_action_titles.pop(key, None)
def _cache_form_action_titles(
self,
mappings: dict[str, str],
pipeline_uuid: str = '',
now: float | None = None,
) -> None:
now = time.monotonic() if now is None else now
self._prune_form_action_titles(now)
group_id = uuid.uuid4().hex
expires_at = now + self._FORM_ACTION_CACHE_TTL
self._form_action_titles.update(
{callback_data: (title, pipeline_uuid, expires_at, group_id) for callback_data, title in mappings.items()}
)
def _take_form_action_context(self, callback_data: str, now: float | None = None) -> tuple[str, str] | None:
"""Consume a callback and invalidate every button from the same form."""
self._prune_form_action_titles(now)
entry = self._form_action_titles.get(callback_data)
if entry is None:
return None
title, pipeline_uuid, _, group_id = entry
group_keys = [
key for key, (_, _, _, cached_group_id) in self._form_action_titles.items() if cached_group_id == group_id
]
for key in group_keys:
self._form_action_titles.pop(key, None)
return title, pipeline_uuid
def _take_form_action_title(self, callback_data: str, now: float | None = None) -> str | None:
context = self._take_form_action_context(callback_data, now)
return context[0] if context else None
def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger):
async def telegram_callback(update: Update, context: ContextTypes.DEFAULT_TYPE):
if update.message.from_user.is_bot:
@@ -224,6 +324,117 @@ class TelegramAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
telegram_callback,
)
)
async def callback_query_handler(update: Update, context: ContextTypes.DEFAULT_TYPE):
query = update.callback_query
await query.answer()
try:
data = json.loads(query.data)
if data.get('form_action') or data.get('f'):
import langbot_plugin.api.entities.builtin.provider.session as provider_session
# workflow_run_id is not in the callback payload (too large
# for Telegram's 64-byte limit). Only w_suffix is sent;
# the runner resolves the full run id from _PENDING_FORMS.
w_suffix = data.get('w', '')
session_key = data.get('session_key') or data.get('s', '')
callback_action = _telegram_form_action_from_callback(data)
action_context = self._take_form_action_context(query.data) if callback_action is not None else None
if callback_action is None or action_context is None:
await self.logger.warning(f'Invalid or stale Telegram form callback: {query.data!r}')
return
action_title, pipeline_uuid = action_context
# Show selected action feedback by editing the original message
try:
original_text = query.message.text or ''
selected_text = f'{original_text}\n\n{action_title}'
await query.edit_message_text(text=selected_text, reply_markup=None)
except Exception:
# If edit fails (e.g. message too long), just pass
pass
if session_key.startswith('group_') or session_key.startswith('g:'):
launcher_type = provider_session.LauncherTypes.GROUP
launcher_id = (
session_key.split(':', 1)[1]
if session_key.startswith('g:')
else session_key[len('group_') :]
)
else:
launcher_type = provider_session.LauncherTypes.PERSON
launcher_id = (
session_key.split(':', 1)[1]
if session_key.startswith('p:')
else session_key[len('person_') :]
)
user_id = str(query.from_user.id)
# Find bot_uuid and pipeline_uuid
bot_uuid = ''
for b in self.ap.platform_mgr.bots:
if b.adapter is self:
bot_uuid = b.bot_entity.uuid
pipeline_uuid = pipeline_uuid or b.bot_entity.use_pipeline_uuid
break
form_action_data = {
# workflow_run_id is intentionally omitted; the runner
# resolves it from w_suffix via _PENDING_FORMS.
'w_suffix': w_suffix,
'user': f'{launcher_type.value}_{launcher_id}',
**callback_action,
}
event_label = 'Form Select' if callback_action.get('_input_progress') else 'Form Action'
message_chain = platform_message.MessageChain(
[platform_message.Plain(text=f'[{event_label}: {action_title}]')]
)
if launcher_type == provider_session.LauncherTypes.GROUP:
synthetic_event = platform_events.GroupMessage(
sender=platform_entities.GroupMember(
id=user_id,
member_name='',
permission=platform_entities.Permission.Member,
group=platform_entities.Group(
id=launcher_id,
name='',
permission=platform_entities.Permission.Member,
),
),
message_chain=message_chain,
source_platform_object=update,
)
else:
synthetic_event = platform_events.FriendMessage(
sender=platform_entities.Friend(
id=user_id,
nickname='',
remark='',
),
message_chain=message_chain,
source_platform_object=update,
)
await self.ap.query_pool.add_query(
bot_uuid=bot_uuid,
launcher_type=launcher_type,
launcher_id=launcher_id,
sender_id=user_id,
message_event=synthetic_event,
message_chain=message_chain,
adapter=self,
pipeline_uuid=pipeline_uuid,
variables={
'_dify_form_action': form_action_data,
'_routed_by_rule': True,
},
)
except Exception:
await self.logger.error(f'Error in telegram callback query: {traceback.format_exc()}')
application.add_handler(CallbackQueryHandler(callback_query_handler))
super().__init__(
config=config,
logger=logger,
@@ -314,23 +525,34 @@ class TelegramAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
args['parse_mode'] = 'MarkdownV2'
return args
async def _delete_group_stream_message(self, chat_mode: str, chat_id: int, stream_id: int | None):
if chat_mode != 'group' or stream_id is None:
return
try:
await self.bot.delete_message(chat_id=chat_id, message_id=stream_id)
except telegram.error.TelegramError:
pass
@staticmethod
def _is_form_placeholder_chunk(text: str) -> bool:
"""Return True for invisible placeholder chunks used to carry forms."""
if not text:
return True
cleaned = text.replace('\u200b', '').replace('\u200c', '').replace('\u200d', '').replace('\ufeff', '').strip()
return cleaned == ''
async def create_message_card(self, message_id, event):
assert isinstance(event.source_platform_object, Update)
update = event.source_platform_object
chat_id = update.effective_chat.id
chat_type = update.effective_chat.type
message_thread_id = update.message.message_thread_id
effective_message = update.effective_message
message_thread_id = getattr(effective_message, 'message_thread_id', None) if effective_message else None
if chat_type == 'private':
draft_id = int(time.time() * 1000)
self.msg_stream_id[message_id] = ('private', draft_id)
args = self._build_message_args(chat_id, 'Thinking...', message_thread_id, draft_id=draft_id)
await self.bot.send_message_draft(**args)
else:
args = self._build_message_args(chat_id, 'Thinking...', message_thread_id)
send_msg = await self.bot.send_message(**args)
self.msg_stream_id[message_id] = ('group', send_msg.message_id)
args = self._build_message_args(chat_id, 'Thinking...', message_thread_id)
send_msg = await self.bot.send_message(**args)
self.msg_stream_id[message_id] = ('message', send_msg.message_id, False)
return True
@@ -347,12 +569,15 @@ class TelegramAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
assert isinstance(message_source.source_platform_object, Update)
update = message_source.source_platform_object
chat_id = update.effective_chat.id
message_thread_id = update.message.message_thread_id
effective_message = update.effective_message
message_thread_id = getattr(effective_message, 'message_thread_id', None) if effective_message else None
if message_id not in self.msg_stream_id:
return
chat_mode, draft_id = self.msg_stream_id[message_id]
stream_state = self.msg_stream_id[message_id]
chat_mode, stream_id = stream_state[:2]
has_visible_content = len(stream_state) > 2 and stream_state[2]
components = await TelegramMessageConverter.yiri2target(message, self.bot)
if not components or components[0]['type'] != 'text':
@@ -361,17 +586,68 @@ class TelegramAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
return
content = components[0]['text']
form_data = getattr(bot_message, '_form_data', None)
if form_data and is_final:
if not has_visible_content:
await self._send_form_action_buttons(message_source, form_data, edit_message_id=stream_id)
else:
await self._send_form_action_buttons(message_source, form_data)
self.msg_stream_id.pop(message_id, None)
return
if self._is_form_placeholder_chunk(content):
if is_final and bot_message.tool_calls is None and not has_visible_content:
await self._delete_group_stream_message(chat_mode, chat_id, stream_id)
self.msg_stream_id.pop(message_id, None)
return
if chat_mode == 'private':
args = self._build_message_args(chat_id, content, message_thread_id, draft_id=draft_id)
await self.bot.send_message_draft(**args)
# Streaming via draft (ephemeral preview in the chat input area)
if (msg_seq - 1) % 8 == 0 or is_final:
args = self._build_message_args(chat_id, content, message_thread_id, draft_id=stream_id)
try:
await self.bot.send_message_draft(**args)
except telegram.error.BadRequest as exc:
if 'Message_too_long' in str(exc):
args['text'] = content[:4000] + '\n\n… (truncated)'
try:
await self.bot.send_message_draft(**args)
except telegram.error.RetryAfter:
pass
else:
pass # Ignore other draft errors (cosmetic)
self.msg_stream_id[message_id] = (chat_mode, stream_id, True)
if is_final and bot_message.tool_calls is None:
del args['draft_id']
await self.bot.send_message(**args)
# Finalise: send the real message, discard the draft
args = self._build_message_args(chat_id, content, message_thread_id)
try:
await self.bot.send_message(**args)
except telegram.error.BadRequest as exc:
if 'Message_too_long' in str(exc):
args['text'] = content[:4000] + '\n\n… (truncated)'
await self.bot.send_message(**args)
else:
raise
self.msg_stream_id.pop(message_id)
else:
stream_id = draft_id
if (msg_seq - 1) % 8 == 0 or is_final:
# Streaming via edit_message_text (persistent message)
if stream_id is None:
args = self._build_message_args(chat_id, content, message_thread_id)
try:
send_msg = await self.bot.send_message(**args)
except telegram.error.BadRequest as exc:
if 'Message_too_long' in str(exc):
args['text'] = self._process_markdown(content[:4000] + '\n\n鈥?(truncated)')
send_msg = await self.bot.send_message(**args)
else:
raise
self.msg_stream_id[message_id] = (chat_mode, send_msg.message_id, True)
if is_final and bot_message.tool_calls is None:
self.msg_stream_id.pop(message_id, None)
return
if not has_visible_content or (msg_seq - 1) % 8 == 0 or is_final:
args = {
'message_id': stream_id,
'chat_id': chat_id,
@@ -379,11 +655,137 @@ class TelegramAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
}
if self.config.get('markdown_card', False):
args['parse_mode'] = 'MarkdownV2'
await self.bot.edit_message_text(**args)
try:
await self.bot.edit_message_text(**args)
except telegram.error.BadRequest as exc:
if 'Message_too_long' in str(exc):
args['text'] = self._process_markdown(content[:4000] + '\n\n… (truncated)')
await self.bot.edit_message_text(**args)
else:
raise
self.msg_stream_id[message_id] = (chat_mode, stream_id, True)
if is_final and bot_message.tool_calls is None:
self.msg_stream_id.pop(message_id)
async def _send_form_action_buttons(
self,
message_source: platform_events.MessageEvent,
form_data: dict,
edit_message_id: int | None = None,
):
"""Send inline keyboard buttons for Dify form fields or actions."""
actions = form_data.get('actions', [])
node_title = form_data.get('node_title', '')
form_content = form_data.get('form_content', '')
workflow_run_id = form_data.get('workflow_run_id', '')
# Telegram callback_data is capped at 64 bytes, so we identify the
# paused workflow by the last 8 chars of workflow_run_id (unique
# within a session with overwhelming probability).
w_suffix = workflow_run_id[-8:] if workflow_run_id else ''
if isinstance(message_source, platform_events.GroupMessage):
session_key = f'g:{message_source.group.id}'
else:
session_key = f'p:{message_source.sender.id}'
current_field = str(form_data.get('_current_input_field') or '').strip()
is_field_step = bool(current_field) and not form_data.get('_action_select_only')
select_field, select_options = _telegram_select_field_options(form_data)
is_select_field = bool(select_field and select_options)
if is_select_field:
choices = [(option, {'x': idx}) for idx, option in enumerate(select_options)]
elif is_field_step:
choices = []
else:
choices = [(action.get('title', action.get('id', '')), {'a': action.get('id', '')}) for action in actions]
keyboard = []
pending_title_mappings: dict[str, str] = {}
oversized = False
buttons_per_row = 2 if is_select_field else 1
current_row = []
for title, choice_data in choices:
callback_payload = {'f': 1, **choice_data, 's': session_key}
if w_suffix:
callback_payload['w'] = w_suffix
callback_data = json.dumps(callback_payload, separators=(',', ':'))
if len(callback_data.encode('utf-8')) > 64:
oversized = True
break
pending_title_mappings[callback_data] = str(title)
current_row.append(InlineKeyboardButton(str(title), callback_data=callback_data))
if len(current_row) == buttons_per_row:
keyboard.append(current_row)
current_row = []
if current_row and not oversized:
keyboard.append(current_row)
update = message_source.source_platform_object
chat_id = update.effective_chat.id
effective_message = update.effective_message
message_thread_id = getattr(effective_message, 'message_thread_id', None) if effective_message else None
heading = f'[{node_title}]'
text_lines = [heading]
if form_content:
text_lines.append(form_content)
if oversized:
# callback_data exceeds Telegram's 64-byte limit — fall back to
# a plain-text numbered list so the user can reply by number.
for idx, (title, _) in enumerate(choices, start=1):
text_lines.append(f' {idx}. {title}')
args = {
'chat_id': chat_id,
'text': '\n\n'.join(text_lines),
}
elif keyboard:
self._cache_form_action_titles(
pending_title_mappings,
str(form_data.get('pipeline_uuid') or ''),
)
reply_markup = InlineKeyboardMarkup(keyboard)
args = {
'chat_id': chat_id,
'text': '\n\n'.join(text_lines),
'reply_markup': reply_markup,
}
elif is_field_step:
args = {
'chat_id': chat_id,
'text': '\n\n'.join(text_lines),
# Telegram privacy-mode bots receive replies to ForceReply
# prompts even when they cannot read ordinary group messages.
'reply_markup': ForceReply(
selective=False,
input_field_placeholder=current_field,
),
}
else:
args = {
'chat_id': chat_id,
'text': '\n\n'.join(text_lines),
}
if message_thread_id:
args['message_thread_id'] = message_thread_id
if edit_message_id is not None:
edit_args = {
'chat_id': chat_id,
'message_id': edit_message_id,
'text': args['text'],
}
edit_args['reply_markup'] = args.get('reply_markup')
try:
await self.bot.edit_message_text(**edit_args)
return
except telegram.error.TelegramError:
await self._delete_group_stream_message('group', chat_id, edit_message_id)
await self.bot.send_message(**args)
def get_launcher_id(self, event: platform_events.MessageEvent) -> str | None:
if not isinstance(event.source_platform_object, Update):
return None
@@ -13,7 +13,7 @@ import langbot_plugin.api.entities.builtin.platform.events as platform_events
import langbot_plugin.api.entities.builtin.platform.entities as platform_entities
import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger
from ...core import app
from .websocket_manager import ws_connection_manager, WebSocketConnection
from .websocket_manager import WebSocketConnection, is_valid_session_id, ws_connection_manager
logger = logging.getLogger(__name__)
@@ -91,6 +91,59 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
self.outbound_message_queue = asyncio.Queue()
self.stream_enabled = True
@staticmethod
def _conversation_key(pipeline_uuid: str, session_id: str | None = None) -> str:
"""Return the history key for a pipeline/client conversation."""
return f'{pipeline_uuid}:{session_id}' if session_id else pipeline_uuid
@staticmethod
def _parse_embed_target(target_id: str) -> tuple[str, str] | None:
"""Extract pipeline and session identifiers from a stable embed launcher."""
target_value = str(target_id)
for prefix in ('websocket_', 'websocketgroup_'):
if target_value.startswith(prefix):
target = target_value[len(prefix) :]
break
else:
return None
if ':' not in target:
return None
pipeline_uuid, session_id = target.rsplit(':', 1)
if not pipeline_uuid or not is_valid_session_id(session_id):
return None
return pipeline_uuid, session_id
@classmethod
async def _get_connection_from_target(cls, target_id: str):
"""Resolve a person or group WebSocket launcher to its connection."""
target_value = str(target_id)
for prefix in ('websocket_', 'websocketgroup_'):
if target_value.startswith(prefix):
target = target_value[len(prefix) :]
break
else:
return None
connection = await ws_connection_manager.get_connection(target)
if connection is not None:
return connection
embed_target = cls._parse_embed_target(target_id)
if embed_target is not None:
pipeline_uuid, session_id = embed_target
return await ws_connection_manager.get_connection_by_session_id(session_id, pipeline_uuid)
return await ws_connection_manager.get_connection_by_session_id(target)
async def _get_message_context(self, message_source) -> tuple[str, str | None]:
"""Resolve the originating pipeline and browser session for a reply."""
sender = getattr(message_source, 'sender', None)
sender_id = getattr(sender, 'id', '')
connection = await self._get_connection_from_target(sender_id)
if connection is not None:
return connection.pipeline_uuid, connection.session_id
embed_target = self._parse_embed_target(sender_id)
if embed_target is not None:
return embed_target
return typing.cast(str, self.ap.platform_mgr.websocket_proxy_bot.bot_entity.use_pipeline_uuid), None
async def send_message(
self,
target_type: str,
@@ -103,15 +156,26 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
target_id 可能是 launcher_id websocket_xxx pipeline_uuid
我们需要尝试两种方式来确保消息能够送达
"""
# 获取当前的 pipeline_uuid
pipeline_uuid = self.ap.platform_mgr.websocket_proxy_bot.bot_entity.use_pipeline_uuid
connection = await self._get_connection_from_target(target_id)
if connection is not None:
pipeline_uuid = connection.pipeline_uuid
session_id = connection.session_id
else:
embed_target = self._parse_embed_target(target_id)
if embed_target is not None:
pipeline_uuid, session_id = embed_target
else:
pipeline_uuid = typing.cast(
str,
self.ap.platform_mgr.websocket_proxy_bot.bot_entity.use_pipeline_uuid,
)
session_id = None
session_type = 'group' if target_type == 'group' else 'person'
conversation_key = self._conversation_key(pipeline_uuid, session_id)
# 选择会话
session = self.websocket_group_session if session_type == 'group' else self.websocket_person_session
# 生成唯一消息ID
msg_id = len(session.get_message_list(pipeline_uuid)) + 1
msg_id = len(session.get_message_list(conversation_key)) + 1
message_data = WebSocketMessage(
id=msg_id,
@@ -122,10 +186,8 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
is_final=True,
)
# 保存到历史记录
session.get_message_list(pipeline_uuid).append(message_data)
session.get_message_list(conversation_key).append(message_data)
# 直接广播到当前pipeline的连接
await ws_connection_manager.broadcast_to_pipeline(
pipeline_uuid,
{
@@ -134,6 +196,7 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
'data': message_data.model_dump(),
},
session_type=session_type,
session_id=session_id,
)
return message_data.model_dump()
@@ -152,12 +215,11 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
else self.websocket_person_session
)
# 从message_source获取pipeline_uuid和connection_id
pipeline_uuid = self.ap.platform_mgr.websocket_proxy_bot.bot_entity.use_pipeline_uuid
pipeline_uuid, session_id = await self._get_message_context(message_source)
session_type = 'group' if isinstance(message_source, platform_events.GroupMessage) else 'person'
conversation_key = self._conversation_key(pipeline_uuid, session_id)
# 生成新的消息ID
msg_id = len(session.get_message_list(pipeline_uuid)) + 1
msg_id = len(session.get_message_list(conversation_key)) + 1
message_data = WebSocketMessage(
id=msg_id,
@@ -168,10 +230,8 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
is_final=True,
)
# 保存到历史记录
session.get_message_list(pipeline_uuid).append(message_data)
session.get_message_list(conversation_key).append(message_data)
# 直接广播到所有该pipeline的连接,包含session_type信息
await ws_connection_manager.broadcast_to_pipeline(
pipeline_uuid,
{
@@ -180,6 +240,7 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
'data': message_data.model_dump(),
},
session_type=session_type,
session_id=session_id,
)
return message_data.model_dump()
@@ -200,10 +261,11 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
else self.websocket_person_session
)
pipeline_uuid = self.ap.platform_mgr.websocket_proxy_bot.bot_entity.use_pipeline_uuid
pipeline_uuid, session_id = await self._get_message_context(message_source)
session_type = 'group' if isinstance(message_source, platform_events.GroupMessage) else 'person'
message_list = session.get_message_list(pipeline_uuid)
stream_message_indexes = session.get_stream_message_indexes(pipeline_uuid)
conversation_key = self._conversation_key(pipeline_uuid, session_id)
message_list = session.get_message_list(conversation_key)
stream_message_indexes = session.get_stream_message_indexes(conversation_key)
# Streaming messages in LangBot have a stable resp_message_id during the same assistant reply.
# Use it as the primary key to avoid overwriting an old card from a previous reply.
@@ -247,7 +309,6 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
if message_is_final and resp_message_id:
stream_message_indexes.pop(resp_message_id, None)
# 直接广播到所有该pipeline的连接,包含session_type信息
await ws_connection_manager.broadcast_to_pipeline(
pipeline_uuid,
{
@@ -256,6 +317,7 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
'data': message_data.model_dump(),
},
session_type=session_type,
session_id=session_id,
)
return message_data.model_dump()
@@ -381,23 +443,19 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
"""
pipeline_uuid = connection.pipeline_uuid
session_type = connection.session_type
conversation_key = self._conversation_key(pipeline_uuid, connection.session_id)
# 获取stream参数,默认为True
self.stream_enabled = message_data.get('stream', True)
# 选择会话
use_session = self.websocket_group_session if session_type == 'group' else self.websocket_person_session
# 解析消息链
message_chain_obj = message_data.get('message', [])
# 处理图片组件:将path转换为base64
await self._process_image_components(message_chain_obj)
message_chain = platform_message.MessageChain.model_validate(message_chain_obj)
# 生成消息ID
message_id = len(use_session.get_message_list(pipeline_uuid)) + 1
message_id = len(use_session.get_message_list(conversation_key)) + 1
# 保存用户消息
user_message = WebSocketMessage(
@@ -409,9 +467,8 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
connection_id=connection.connection_id,
is_final=True, # 用户消息始终是完整的,非流式
)
use_session.get_message_list(pipeline_uuid).append(user_message)
use_session.get_message_list(conversation_key).append(user_message)
# 广播用户消息到所有连接(包括发送者),包含session_type信息
await ws_connection_manager.broadcast_to_pipeline(
pipeline_uuid,
{
@@ -420,25 +477,27 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
'data': user_message.model_dump(),
},
session_type=session_type,
session_id=connection.session_id,
)
# 添加消息源
message_chain.insert(0, platform_message.Source(id=message_id, time=datetime.now().timestamp()))
# 创建事件
launcher_id = f'{pipeline_uuid}:{connection.session_id}' if connection.session_id else connection.connection_id
if session_type == 'person':
sender = platform_entities.Friend(
id=f'websocket_{connection.connection_id}', nickname='User', remark='User'
)
sender = platform_entities.Friend(id=f'websocket_{launcher_id}', nickname='User', remark='User')
event = platform_events.FriendMessage(
sender=sender, message_chain=message_chain, time=datetime.now().timestamp()
)
else:
group = platform_entities.Group(
id='websocketgroup', name='Group', permission=platform_entities.Permission.Member
id=f'websocketgroup_{launcher_id}' if connection.session_id else 'websocketgroup',
name='Group',
permission=platform_entities.Permission.Member,
)
sender = platform_entities.GroupMember(
id=f'websocket_{connection.connection_id}',
id=f'websocket_{launcher_id}',
member_name='User',
group=group,
permission=platform_entities.Permission.Member,
@@ -468,22 +527,47 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
if event.__class__ in listeners:
asyncio.create_task(listeners[event.__class__](event, callback_adapter))
def get_websocket_messages(self, pipeline_uuid: str, session_type: str) -> list[dict]:
"""获取消息历史"""
if session_type == 'person':
return [message.model_dump() for message in self.websocket_person_session.get_message_list(pipeline_uuid)]
else:
return [message.model_dump() for message in self.websocket_group_session.get_message_list(pipeline_uuid)]
def get_websocket_messages(
self,
pipeline_uuid: str,
session_type: str,
session_id: str | None = None,
) -> list[dict]:
"""Return history for one pipeline/client conversation."""
conversation_key = self._conversation_key(pipeline_uuid, session_id)
session = self.websocket_person_session if session_type == 'person' else self.websocket_group_session
return [message.model_dump() for message in session.message_lists.get(conversation_key, [])]
def reset_session(self, pipeline_uuid: str, session_type: str):
"""重置会话"""
if session_type == 'person':
if pipeline_uuid in self.websocket_person_session.message_lists:
self.websocket_person_session.message_lists[pipeline_uuid] = []
if pipeline_uuid in self.websocket_person_session.stream_message_indexes:
self.websocket_person_session.stream_message_indexes[pipeline_uuid] = {}
else:
if pipeline_uuid in self.websocket_group_session.message_lists:
self.websocket_group_session.message_lists[pipeline_uuid] = []
if pipeline_uuid in self.websocket_group_session.stream_message_indexes:
self.websocket_group_session.stream_message_indexes[pipeline_uuid] = {}
def reset_session(
self,
pipeline_uuid: str,
session_type: str,
session_id: str | None = None,
):
"""Reset one pipeline/client conversation."""
conversation_key = self._conversation_key(pipeline_uuid, session_id)
session = self.websocket_person_session if session_type == 'person' else self.websocket_group_session
if conversation_key in session.message_lists:
session.message_lists[conversation_key] = []
if conversation_key in session.stream_message_indexes:
session.stream_message_indexes[conversation_key] = {}
if session_id:
launcher_id = (
f'websocketgroup_{pipeline_uuid}:{session_id}'
if session_type == 'group'
else f'websocket_{pipeline_uuid}:{session_id}'
)
self.ap.sess_mgr.session_list = [
candidate_session
for candidate_session in self.ap.sess_mgr.session_list
if not (
str(
candidate_session.launcher_type.value
if hasattr(candidate_session.launcher_type, 'value')
else candidate_session.launcher_type
)
== session_type
and str(candidate_session.launcher_id) == launcher_id
)
]
@@ -9,6 +9,16 @@ from datetime import datetime
import pydantic
logger = logging.getLogger(__name__)
_SESSION_FILTER_UNSET = object()
def is_valid_session_id(value: str) -> bool:
"""Accept only canonical random UUIDs for client conversation identifiers."""
try:
parsed = uuid.UUID(value)
except (ValueError, TypeError, AttributeError):
return False
return parsed.version == 4 and str(parsed) == value
class WebSocketConnection(pydantic.BaseModel):
@@ -25,6 +35,9 @@ class WebSocketConnection(pydantic.BaseModel):
session_type: str # 'person' or 'group'
"""会话类型"""
session_id: str | None = None
"""Optional client conversation identifier used by embed widgets."""
websocket: typing.Any = pydantic.Field(exclude=True)
"""WebSocket连接对象 (quart.websocket)"""
@@ -65,13 +78,15 @@ class WebSocketConnectionManager:
websocket: typing.Any,
pipeline_uuid: str,
session_type: str,
metadata: dict = None,
metadata: dict | None = None,
session_id: str | None = None,
) -> WebSocketConnection:
"""添加新的WebSocket连接"""
"""Register a WebSocket connection and its optional embed session."""
async with self._lock:
connection = WebSocketConnection(
pipeline_uuid=pipeline_uuid,
session_type=session_type,
session_id=session_id,
websocket=websocket,
metadata=metadata or {},
)
@@ -120,10 +135,25 @@ class WebSocketConnectionManager:
logger.debug(f'WebSocket connection disconnected: {connection_id}')
async def get_connection(self, connection_id: str) -> typing.Optional[WebSocketConnection]:
"""获取指定连接"""
async def get_connection(self, connection_id: str) -> WebSocketConnection | None:
"""Get a connection by its transport identifier."""
return self.connections.get(connection_id)
async def get_connection_by_session_id(
self,
session_id: str,
pipeline_uuid: str | None = None,
) -> WebSocketConnection | None:
"""Get an active embed connection by its stable browser session identifier."""
for connection in self.connections.values():
if (
connection.session_id == session_id
and connection.is_active
and (pipeline_uuid is None or connection.pipeline_uuid == pipeline_uuid)
):
return connection
return None
async def get_connections_by_pipeline(self, pipeline_uuid: str) -> list[WebSocketConnection]:
"""获取指定流水线的所有连接"""
connection_ids = self.pipeline_connections.get(pipeline_uuid, set())
@@ -134,20 +164,30 @@ class WebSocketConnectionManager:
connection_ids = self.session_connections.get(session_type, set())
return [self.connections[cid] for cid in connection_ids if cid in self.connections]
async def broadcast_to_pipeline(self, pipeline_uuid: str, message: dict, session_type: str = None):
"""向指定流水线的所有连接广播消息
async def broadcast_to_pipeline(
self,
pipeline_uuid: str,
message: dict,
session_type: str | None = None,
session_id: typing.Any = _SESSION_FILTER_UNSET,
):
"""Broadcast a message to matching connections for one pipeline.
Args:
pipeline_uuid: 流水线UUID
message: 要广播的消息
session_type: 可选的会话类型过滤器如果提供则只向匹配的session_type连接广播
pipeline_uuid: Pipeline identifier.
message: Serialized message to enqueue.
session_type: Optional session-type filter.
session_id: Embed conversation filter. Omit it to broadcast across
conversations; pass ``None`` to target non-embed connections.
"""
connections = await self.get_connections_by_pipeline(pipeline_uuid)
# 如果指定了session_type,只向匹配的连接广播
if session_type is not None:
connections = [conn for conn in connections if conn.session_type == session_type]
if session_id is not _SESSION_FILTER_UNSET:
connections = [conn for conn in connections if conn.session_id == session_id]
tasks = []
for conn in connections:
tasks.append(self.send_to_connection(conn.connection_id, message))
+424 -5
View File
@@ -11,7 +11,13 @@ import langbot_plugin.api.entities.builtin.platform.events as platform_events
import langbot_plugin.api.entities.builtin.platform.entities as platform_entities
from ..logger import EventLogger
from langbot.libs.wecom_ai_bot_api.wecombotevent import WecomBotEvent
from langbot.libs.wecom_ai_bot_api.api import WecomBotClient
from langbot.libs.wecom_ai_bot_api.api import (
WecomBotClient,
extract_template_card_action,
extract_template_card_event_payload,
extract_template_card_selections,
parse_select_button_action,
)
from langbot.libs.wecom_ai_bot_api.ws_client import WecomBotWsClient
@@ -296,6 +302,7 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
listeners: dict = {}
_stream_to_monitoring_msg: dict = {} # Maps stream_id to (monitoring_message_id, timestamp)
_STREAM_MAPPING_TTL = 600 # 10 minutes
ap: typing.Any = None
def __init__(self, config: dict, logger: EventLogger):
enable_webhook = config.get('enable-webhook', False)
@@ -336,6 +343,25 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
_stream_to_monitoring_msg={},
)
# Both WecomBotClient (webhook) and WecomBotWsClient (ws long-conn)
# expose ``set_card_action_callback``. Wire the click handler so
# Dify human-input button taps resume the workflow on either mode.
if hasattr(self.bot, 'set_card_action_callback'):
self.bot.set_card_action_callback(self._on_card_action)
# Hand the client a `source` block so every interactive
# template_card it emits carries the LangBot logo + name at the
# top — the WeCom analogue of DingTalk's Avatar header.
# Always on; icon_url accepts plain HTTPS URLs (no upload needed).
if hasattr(self.bot, 'set_card_source'):
self.bot.set_card_source(
{
'icon_url': 'https://raw.githubusercontent.com/RockChinQ/LangBot/master/res/logo-blue.png',
'desc': 'LangBot',
'desc_color': 0,
}
)
async def reply_message(
self,
message_source: platform_events.MessageEvent,
@@ -345,15 +371,37 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
content = await self.message_converter.yiri2target(message)
_ws_mode = not self.config.get('enable-webhook', False)
event = message_source.source_platform_object
# Synthetic events (button-click resume queries) have no inbound
# platform object. Fall back to a proactive send so error
# messages and one-shot replies still reach the user.
if event is None:
if _ws_mode:
if isinstance(message_source, platform_events.GroupMessage):
chat_id = str(message_source.group.id)
else:
chat_id = str(message_source.sender.id)
try:
await self.bot.send_message(chat_id, content)
except Exception:
await self.logger.error(
f'WeComBot: proactive reply for synthetic event failed: {traceback.format_exc()}'
)
else:
await self.logger.warning(
'WeComBot webhook mode cannot reply to a synthetic event '
'(no req_id and no proactive-send credentials); dropping.'
)
return
if _ws_mode:
event = message_source.source_platform_object
req_id = event.get('req_id', '')
req_id = event.get('req_id', '') if isinstance(event, dict) else getattr(event, 'req_id', '')
if req_id:
await self.bot.reply_text(req_id, content)
else:
await self.bot.set_message(event.message_id, content)
else:
await self.bot.set_message(message_source.source_platform_object.message_id, content)
await self.bot.set_message(event.message_id, content)
async def reply_message_chunk(
self,
@@ -364,9 +412,56 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
is_final: bool = False,
):
content = await self.message_converter.yiri2target(message)
msg_id = message_source.source_platform_object.message_id
_ws_mode = not self.config.get('enable-webhook', False)
# Synthetic events (e.g. button-click triggered form resume) have
# no inbound platform message — no msg_id, no req_id, no stream
# session. The output must go via the proactive-send path instead
# of the stream/reply path.
spo = message_source.source_platform_object
if spo is None:
return await self._handle_synthetic_chunk(message_source, bot_message, content, is_final, _ws_mode)
msg_id = spo.message_id
# Dify human-input pause: when the runner attaches `_form_data` to
# the final chunk, hand the button_interaction card off to the
# underlying client. In webhook mode the card is queued for the
# next followup poll; in ws mode it's sent as a reply frame
# immediately. Falls back to plain text when the bot has no active
# stream session for this msg_id (rare).
form_data = getattr(bot_message, '_form_data', None)
if form_data and is_final:
if hasattr(self.bot, 'push_form_pause'):
ok, stream_id, task_id = await self.bot.push_form_pause(msg_id, form_data)
if ok:
await self.logger.info(
f'WeComBot: pending button_interaction registered '
f'stream_id={stream_id} task_id={task_id} ws_mode={_ws_mode}'
)
return {'stream': True, 'form': True, 'task_id': task_id}
await self.logger.warning(
'WeComBot: cannot register form pause (no active stream session); falling back to plain text'
)
try:
from langbot.pkg.provider.runners.difysvapi import _format_human_input_text
fallback = _format_human_input_text(
form_data.get('node_title', ''),
form_data.get('form_content', ''),
form_data.get('actions', []) or [],
)
except Exception:
fallback = content or '(人工输入)'
if _ws_mode:
event = message_source.source_platform_object
req_id = event.get('req_id', '') if isinstance(event, dict) else getattr(event, 'req_id', '')
if req_id:
await self.bot.reply_text(req_id, fallback)
else:
await self.bot.set_message(msg_id, fallback)
return {'stream': False, 'form': True, 'fallback': True}
if _ws_mode:
success = await self.bot.push_stream_chunk(msg_id, content, is_final=is_final)
if not success and is_final:
@@ -385,6 +480,142 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
"""Whether streaming output is enabled for this bot instance."""
return self.config.get('enable-stream-reply', True)
async def _handle_synthetic_chunk(
self,
message_source: platform_events.MessageEvent,
bot_message,
content: str,
is_final: bool,
ws_mode: bool,
) -> dict:
"""Handle reply_message_chunk for synthetic events (button clicks).
Synthetic events have no inbound message no msg_id, no req_id,
no stream session. We can't do incremental streaming, so we
buffer chunks per-conversation and flush on ``is_final`` via the
proactive send path.
Buffer keyed by ``(launcher_type, launcher_id)`` from the
synthetic event itself. Only ws mode has a usable proactive-send
path right now (``ws_client.send_message`` /
``ws_client.send_template_card``); webhook mode requires a
corpid/secret we don't have, so it logs and drops.
"""
if isinstance(message_source, platform_events.GroupMessage):
chat_id = str(message_source.group.id)
else:
chat_id = str(message_source.sender.id)
form_data = getattr(bot_message, '_form_data', None)
# Buffer streaming content until is_final.
buf_key = chat_id
if not hasattr(self, '_synthetic_buffers'):
# Attribute-not-declared trick: pydantic forbids dynamic attrs
# on the model, but plain instance dicts via object.__setattr__
# do work. Lazy-create on first call.
object.__setattr__(self, '_synthetic_buffers', {})
buffers: dict[str, str] = self._synthetic_buffers
if content and not form_data:
previous = buffers.get(buf_key, '')
if previous and content.startswith(previous):
buffers[buf_key] = content
elif previous and previous.endswith(content):
buffers[buf_key] = previous
else:
buffers[buf_key] = previous + content
if not is_final:
return {'stream': True, 'synthetic': True, 'buffered': True}
final_content = buffers.pop(buf_key, '')
if content:
if final_content and content.startswith(final_content):
final_content = content
elif final_content and final_content.endswith(content):
pass
else:
final_content = final_content + content
if not ws_mode:
await self.logger.warning(
'WeComBot webhook mode cannot proactively push synthetic-event '
'output (no corpid/secret); the resume reply is dropped. '
f'content_len={len(final_content)} form_data_present={form_data is not None}'
)
return {'stream': False, 'synthetic': True, 'dropped': True}
# ws mode: proactive send.
try:
if form_data:
# Determine user_id / chat_id for the routing context of any
# subsequent click on this card.
if isinstance(message_source, platform_events.GroupMessage):
routing_chat_id = str(message_source.group.id)
routing_user_id = str(message_source.sender.id)
else:
routing_chat_id = ''
routing_user_id = str(message_source.sender.id)
payload = self._build_button_interaction_payload_from_form(
form_data,
user_id=routing_user_id,
chat_id=routing_chat_id,
)
await self.bot.send_template_card(chat_id, payload)
await self.logger.info(
f'WeComBot ws: proactively sent template_card for synthetic event '
f'chat_id={chat_id} form_token={form_data.get("form_token")!r} '
f'workflow_run_id={form_data.get("workflow_run_id")!r}'
)
elif final_content:
await self.bot.send_message(chat_id, final_content)
await self.logger.info(
f'WeComBot ws: proactively sent text for synthetic event chat_id={chat_id} len={len(final_content)}'
)
except Exception:
await self.logger.error(f'WeComBot: synthetic event proactive send failed: {traceback.format_exc()}')
return {'stream': False, 'synthetic': True, 'error': True}
return {'stream': True, 'synthetic': True}
def _build_button_interaction_payload_from_form(
self, form_data: dict, *, user_id: str = '', chat_id: str = ''
) -> dict:
"""Build a button_interaction payload + track task_id for click resolution.
Unlike the inbound-event path (where push_form_pause registers the
task_id with the active stream session), proactive sends still
need the task_id registered so button clicks find pending_form.
For ws mode we stash it directly on the ws_client's pending dict.
"""
from langbot.libs.wecom_ai_bot_api.api import build_human_input_template_card_payload
import secrets as _secrets
task_id = f'dify-{_secrets.token_hex(12)}'
source = getattr(self.bot, 'card_source', None)
payload = build_human_input_template_card_payload(
form_data,
task_id,
source=source,
select_as_buttons=not self.config.get('enable-webhook', False),
)
# Register task_id → form_data so the click callback can find it.
# user_id / chat_id are required so _on_card_action can route the
# resulting synthetic query back to the right user. msg_id / req_id
# / stream_id are intentionally empty — synthetic cards have no
# inbound message to anchor on.
if hasattr(self.bot, '_pending_forms_by_task'):
self.bot._pending_forms_by_task[task_id] = {
'form_data': form_data,
'msg_id': '',
'user_id': user_id,
'chat_id': chat_id,
'stream_id': '',
'req_id': '',
}
return payload
async def send_message(self, target_type, target_id, message):
_ws_mode = not self.config.get('enable-webhook', False)
if _ws_mode:
@@ -531,3 +762,191 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
async def is_muted(self, group_id: int) -> bool:
pass
# ------------------------------------------------------------------
# Dify human-input button-interaction click handling
# ------------------------------------------------------------------
async def _on_card_action(self, session, action_id: str, task_id: str, raw_event: dict) -> None:
"""Translate a button click on a button_interaction card into a
synthetic ``_dify_form_action`` query enqueued on the pool.
Pattern mirrors DingTalk / Lark / Telegram so the runner's
``_merge_pending_form_action`` path resumes the workflow.
"""
import langbot_plugin.api.entities.builtin.provider.session as provider_session
form = session.pending_form or {}
await self.logger.info(
f'WeComBot _on_card_action: task_id={task_id} action_id={action_id!r} '
f'form_token={form.get("form_token")!r} workflow_run_id={form.get("workflow_run_id")!r} '
f'session.user_id={session.user_id!r} session.chat_id={session.chat_id!r}'
)
actions = form.get('actions') or []
tce = extract_template_card_event_payload(raw_event) if isinstance(raw_event, dict) else {}
_, _, card_type = extract_template_card_action(tce)
selections = extract_template_card_selections(tce, form)
if not selections:
selections = parse_select_button_action(action_id, form)
await self.logger.info(
f'WeComBot template_card selections: task_id={task_id} card_type={card_type} selections={selections}'
)
if card_type == 'multiple_interaction' and not selections:
await self.logger.warning(
f'WeComBot: multiple_interaction callback has no parseable selections; raw={str(tce)[:1000]}'
)
return
is_select_submit = card_type == 'multiple_interaction' or bool(selections)
clean_action_id = '' if is_select_submit else (action_id or '').strip()
action_title = clean_action_id
for a in actions:
if str(a.get('id', '')) == clean_action_id:
action_title = a.get('title') or clean_action_id
break
inputs = dict(form.get('inputs') or {})
inputs.update(selections)
def _missing_fields_after_select() -> list[str]:
missing: list[str] = []
for field in form.get('input_defs') or form.get('all_input_defs') or []:
field_name = str(field.get('output_variable_name') or '').strip()
if not field_name:
continue
if inputs.get(field_name) in (None, '', []):
missing.append(field_name)
return missing
input_progress = False
if is_select_submit:
missing_fields = _missing_fields_after_select()
if not missing_fields and len(actions) == 1:
action = actions[0]
clean_action_id = str(action.get('id') or '').strip()
action_title = action.get('title') or clean_action_id
elif not missing_fields and len(actions) > 1:
if not self.config.get('enable-webhook', False):
action_form_data = {
'form_content': form.get('raw_form_content') or form.get('form_content') or '',
'raw_form_content': form.get('raw_form_content') or form.get('form_content') or '',
'input_defs': [],
'all_input_defs': form.get('all_input_defs') or form.get('input_defs') or [],
'inputs': inputs,
'actions': actions,
'node_title': form.get('node_title', ''),
'workflow_run_id': form.get('workflow_run_id', ''),
'form_token': form.get('form_token', ''),
'pipeline_uuid': form.get('pipeline_uuid', ''),
'_action_select_only': True,
}
target_chat_id = session.chat_id or session.user_id or ''
try:
payload = self._build_button_interaction_payload_from_form(
action_form_data,
user_id=session.user_id or '',
chat_id=session.chat_id or '',
)
await self.bot.send_template_card(target_chat_id, payload)
await self.logger.info(
f'WeComBot: sent action-select button card after select submit '
f'task_id={task_id} action_count={len(actions)}'
)
except Exception:
await self.logger.error(
f'WeComBot: failed to send action-select button card: {traceback.format_exc()}'
)
return
await self.logger.warning(
'WeComBot webhook mode cannot proactively send action-select button card after select submit'
)
return
else:
input_progress = True
action_title = 'Submit'
launcher_id = session.user_id or session.chat_id or ''
sender_user_id = session.user_id or launcher_id
# WeCom AI bot has both single-chat and group-chat; chat_id present
# indicates group context.
if session.chat_id:
launcher_type = provider_session.LauncherTypes.GROUP
launcher_id = session.chat_id
else:
launcher_type = provider_session.LauncherTypes.PERSON
launcher_id = session.user_id or ''
form_action_data = {
'form_token': form.get('form_token', ''),
'workflow_run_id': form.get('workflow_run_id', ''),
'action_id': clean_action_id,
'action_title': action_title,
'node_title': form.get('node_title', ''),
'user': f'{launcher_type.value}_{launcher_id}',
'inputs': inputs,
}
if input_progress:
form_action_data['_input_progress'] = True
message_chain = platform_message.MessageChain([platform_message.Plain(text=f'[Form Action: {action_title}]')])
if launcher_type == provider_session.LauncherTypes.GROUP:
synthetic_event = platform_events.GroupMessage(
sender=platform_entities.GroupMember(
id=sender_user_id,
member_name='',
permission=platform_entities.Permission.Member,
group=platform_entities.Group(
id=launcher_id,
name='',
permission=platform_entities.Permission.Member,
),
special_title='',
),
message_chain=message_chain,
time=int(time.time()),
source_platform_object=None,
)
else:
synthetic_event = platform_events.FriendMessage(
sender=platform_entities.Friend(
id=sender_user_id,
nickname='',
remark='',
),
message_chain=message_chain,
time=int(time.time()),
source_platform_object=None,
)
if self.ap is None:
await self.logger.error('WeComBot: ap not injected; cannot enqueue button-click query')
return
bot_uuid = ''
pipeline_uuid = form.get('pipeline_uuid') or None
for bot in self.ap.platform_mgr.bots:
if bot.adapter is self:
bot_uuid = bot.bot_entity.uuid
pipeline_uuid = pipeline_uuid or bot.bot_entity.use_pipeline_uuid
break
try:
await self.ap.query_pool.add_query(
bot_uuid=bot_uuid,
launcher_type=launcher_type,
launcher_id=launcher_id,
sender_id=sender_user_id,
message_event=synthetic_event,
message_chain=message_chain,
adapter=self,
pipeline_uuid=pipeline_uuid,
variables={
'_dify_form_action': form_action_data,
'_routed_by_rule': True,
},
)
await self.logger.info(f'WeComBot: button-click query enqueued action_id={clean_action_id!r}')
except Exception:
await self.logger.error(f'WeComBot: enqueue button-click query failed: {traceback.format_exc()}')
+23 -1
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
import typing
import asyncio
import traceback
import uuid
import datetime
import pydantic
@@ -182,7 +183,28 @@ class WecomCSAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
)
async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):
pass
if target_type != 'person':
raise ValueError('WeCom customer service only supports sending messages to person targets')
open_kfid = self.bot_account_id
external_userid = target_id
if '|' in target_id:
open_kfid, external_userid = target_id.split('|', 1)
if external_userid.startswith('u'):
external_userid = external_userid[1:]
if not open_kfid:
raise ValueError('WeCom customer service open_kfid is required before sending messages')
content_list = await WecomMessageConverter.yiri2target(message, self.bot)
for content in content_list:
msgid = f'langbot_{uuid.uuid4().hex}'
if content['type'] == 'text':
await self.bot.send_text_msg(
open_kfid=open_kfid,
external_userid=external_userid,
msgid=msgid,
content=content['content'],
)
def set_bot_uuid(self, bot_uuid: str):
"""设置 bot UUID(用于生成 webhook URL"""
+14
View File
@@ -737,6 +737,8 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
event_ctx = context.EventContext.from_event(event)
if not self.is_enable_plugin:
event_ctx._emitted_plugins = []
event_ctx._response_sources = []
return event_ctx
# Pass include_plugins to runtime for filtering
@@ -745,9 +747,21 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
)
event_ctx = context.EventContext.model_validate(event_ctx_result['event_context'])
event_ctx._emitted_plugins = event_ctx_result.get('emitted_plugins', [])
if 'response_sources' in event_ctx_result:
event_ctx._response_sources = event_ctx_result['response_sources']
return event_ctx
async def notify_plugin_diagnostic(self, diagnostic: dict[str, Any]) -> None:
"""Best-effort diagnostic forwarding to the plugin runtime."""
if not self.is_enable_plugin:
return
try:
await self.handler.notify_plugin_diagnostic(diagnostic)
except Exception as e:
self.ap.logger.debug(f'Plugin diagnostic forwarding skipped: {e}')
async def list_tools(self, bound_plugins: list[str] | None = None) -> list[ComponentManifest]:
if not self.is_enable_plugin:
return []
+21
View File
@@ -26,6 +26,15 @@ from ..core import app
from ..utils import constants
class _RawAction:
def __init__(self, value: str):
self.value = value
def _langbot_to_runtime_action(enum_name: str, fallback_value: str) -> Any:
return getattr(LangBotToRuntimeAction, enum_name, _RawAction(fallback_value))
def _make_rag_error_response(error: Exception, error_type: str, **extra_context) -> handler.ActionResponse:
"""Create a clean error response for RAG operations.
@@ -923,6 +932,18 @@ class RuntimeConnectionHandler(handler.Handler):
return result
async def notify_plugin_diagnostic(self, diagnostic: dict[str, Any]) -> dict[str, Any]:
"""Notify the plugin runtime about a best-effort plugin diagnostic.
This intentionally uses the raw protocol string instead of a SDK enum so
LangBot can keep running with older langbot-plugin versions.
"""
return await self.call_action(
_langbot_to_runtime_action('PLUGIN_DIAGNOSTIC', 'plugin_diagnostic'),
diagnostic,
timeout=5,
)
async def list_tools(self, include_plugins: list[str] | None = None) -> list[dict[str, Any]]:
"""List tools"""
result = await self.call_action(
@@ -13,6 +13,151 @@ import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
import langbot_plugin.api.entities.builtin.provider.message as provider_message
class _ThinkStripState:
"""Stateful filter that drops think blocks across chunks."""
_THINK_OPEN = '<think>'
_THINK_CLOSE = '</think>'
_LEGACY_OPEN = 'CRETIRE_REASONING_BEGINk'
_LEGACY_CLOSE = 'CRETIRE_REASONING_ENDk'
def __init__(self) -> None:
self._pairs: tuple[tuple[str, str], ...] = (
(self._THINK_OPEN, self._THINK_CLOSE),
(self._LEGACY_OPEN, self._LEGACY_CLOSE),
)
self._open_tags = tuple(open_tag for open_tag, _close_tag in self._pairs)
self._buf = ''
self._close_tag: str | None = None
self._pending_initial = True
def feed(self, chunk: str) -> str:
"""Feed a streaming delta and return user-visible content."""
if not chunk:
return chunk
text = self._buf + chunk
if self._close_tag is not None:
return self._consume_think_body(text)
return self._process_visible_text(text)
def flush(self) -> str:
"""Release buffered visible content when the stream ends."""
if self._close_tag is not None:
self._buf = ''
self._close_tag = None
return ''
pending, self._buf = self._buf, ''
self._close_tag = None
return pending
def _consume_think_body(self, text: str) -> str:
close_tag = self._close_tag
if close_tag is None:
return text
close_idx = text.find(close_tag)
if close_idx != -1:
self._close_tag = None
self._buf = ''
self._pending_initial = False
return self._process_visible_text(text[close_idx + len(close_tag) :])
self._buf = self._close_prefix(text, close_tag)
return ''
def _process_visible_text(self, text: str) -> str:
out: list[str] = []
index = 0
while index < len(text):
if self._pending_initial:
open_idx, open_tag, close_tag = self._find_next_open(text, index)
orphan_close_idx, orphan_close_tag = self._find_next_close(text, index)
if orphan_close_idx != -1 and (open_idx == -1 or orphan_close_idx < open_idx):
self._pending_initial = False
index = orphan_close_idx + len(orphan_close_tag)
continue
if open_idx == -1:
self._buf = text[index:]
return ''.join(out)
if open_idx > index:
self._pending_initial = False
out.append(text[index:open_idx])
index = open_idx
continue
open_idx, open_tag, close_tag = self._find_next_open(text, index)
if open_idx == -1:
emit_end = self._visible_emit_end(text, index)
out.append(text[index:emit_end])
if emit_end > index:
self._pending_initial = False
self._buf = text[emit_end:]
return ''.join(out)
out.append(text[index:open_idx])
if open_idx > index:
self._pending_initial = False
body_start = open_idx + len(open_tag)
close_idx = text.find(close_tag, body_start)
if close_idx == -1:
self._close_tag = close_tag
self._buf = self._close_prefix(text[body_start:], close_tag)
return ''.join(out)
self._pending_initial = False
index = close_idx + len(close_tag)
self._buf = ''
return ''.join(out)
def _find_next_open(self, text: str, start: int) -> tuple[int, str, str]:
best_idx = -1
best_open = ''
best_close = ''
for open_tag, close_tag in self._pairs:
idx = text.find(open_tag, start)
if idx != -1 and (best_idx == -1 or idx < best_idx):
best_idx = idx
best_open = open_tag
best_close = close_tag
return best_idx, best_open, best_close
def _find_next_close(self, text: str, start: int) -> tuple[int, str]:
best_idx = -1
best_close = ''
for _open_tag, close_tag in self._pairs:
idx = text.find(close_tag, start)
if idx != -1 and (best_idx == -1 or idx < best_idx):
best_idx = idx
best_close = close_tag
return best_idx, best_close
def _visible_emit_end(self, text: str, start: int) -> int:
visible = text[start:]
limit = min(len(visible), max(len(open_tag) for open_tag in self._open_tags) - 1)
for keep in range(limit, 0, -1):
suffix = visible[-keep:]
if any(open_tag.startswith(suffix) for open_tag in self._open_tags):
return len(text) - keep
return len(text)
@staticmethod
def _close_prefix(text: str, close_tag: str) -> str:
limit = min(len(text), len(close_tag) - 1)
for keep in range(limit, 0, -1):
suffix = text[-keep:]
if close_tag.startswith(suffix):
return suffix
return ''
class LiteLLMRequester(requester.ProviderAPIRequester):
"""LiteLLM unified API requester supporting chat, embedding, and rerank."""
@@ -237,6 +382,25 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
return req_messages
_THINK_PATTERNS: tuple[str, ...] = (
r'^\s*(?:(?!<think>).)*?</think>\s*',
r'^\s*(?:(?!CRETIRE_REASONING_BEGINk).)*?CRETIRE_REASONING_ENDk\s*',
r'<think>.*?</think>',
r'CRETIRE_REASONING_BEGINk.*?CRETIRE_REASONING_ENDk',
)
@classmethod
def _strip_think(cls, content: str) -> str:
"""Strip chain-of-thought blocks from ``content``."""
if not content:
return content
import re
for pattern in cls._THINK_PATTERNS:
content = re.sub(pattern, '', content, flags=re.DOTALL)
return content.strip()
def _process_thinking_content(self, content: str, reasoning_content: str | None, remove_think: bool) -> str:
"""Process thinking/reasoning content.
@@ -248,20 +412,12 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
Returns:
Processed content string
"""
# Extract and handle thinking tags
if content and 'CRETIRE_REASONING_BEGINk' in content and 'CRETIRE_REASONING_ENDk' in content:
import re
if remove_think and content:
content = self._strip_think(content)
think_pattern = r'CRETIRE_REASONING_BEGINk(.*?)CRETIRE_REASONING_ENDk'
if reasoning_content and not remove_think:
content = f'<think>\n{reasoning_content}\n</think>\n{content or ""}'.strip()
if remove_think:
# Remove thinking tags and their content from output
content = re.sub(think_pattern, '', content, flags=re.DOTALL).strip()
# else: preserve thinking content as-is
# Handle separate reasoning_content field
# Currently we don't include reasoning_content in user-facing output regardless of remove_think
# because it's typically internal model reasoning, not user-visible thinking
return content or ''
@staticmethod
@@ -570,6 +726,7 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
chunk_idx = 0
role = 'assistant'
tool_call_state: dict[int, dict[str, typing.Any]] = {}
think_state = _ThinkStripState() if remove_think else None
try:
response = await acompletion(**args)
@@ -613,6 +770,12 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
# Use reasoning_content as the displayed content
delta_content = reasoning_content
if think_state is not None and delta_content:
delta_content = think_state.feed(delta_content)
if not delta_content:
chunk_idx += 1
continue
tool_calls = self._normalize_stream_tool_calls(delta.get('tool_calls'), tool_call_state)
if chunk_idx == 0 and not delta_content and not tool_calls:
@@ -634,6 +797,15 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
yield provider_message.MessageChunk(**chunk_data)
chunk_idx += 1
if think_state is not None:
pending_content = think_state.flush()
if pending_content:
yield provider_message.MessageChunk(
role=role,
content=pending_content,
is_final=True,
)
except Exception as e:
self._handle_litellm_error(e)
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="TokenLab">
<rect width="64" height="64" rx="14" fill="#111827"/>
<path fill="#38bdf8" d="M17 14h30v8H36v28h-8V22H17z"/>
<path fill="#a7f3d0" d="M40 30h8v20H28v-8h12z"/>
</svg>

After

Width:  |  Height:  |  Size: 265 B

Some files were not shown because too many files have changed in this diff Show More