From 76ef93eb40938856c6982daf0a39c3dd6f25dbe6 Mon Sep 17 00:00:00 2001 From: fr3on Date: Thu, 10 Sep 2026 10:35:49 +0300 Subject: [PATCH 1/4] Add slack-support-agent plugin --- fr3on/slack-support-agent/.difyignore | 7 + fr3on/slack-support-agent/LICENSE | 21 + fr3on/slack-support-agent/PRIVACY.md | 31 + fr3on/slack-support-agent/README.md | 103 +++ fr3on/slack-support-agent/_assets/icon.svg | 27 + fr3on/slack-support-agent/endpoints/slack.py | 698 ++++++++++++++++++ .../slack-support-agent/endpoints/slack.yaml | 5 + fr3on/slack-support-agent/group/slack.yaml | 98 +++ fr3on/slack-support-agent/main.py | 6 + fr3on/slack-support-agent/manifest.yaml | 51 ++ fr3on/slack-support-agent/requirements.txt | 4 + .../slack-support-agent-0.0.1.difypkg | Bin 0 -> 16435 bytes 12 files changed, 1051 insertions(+) create mode 100644 fr3on/slack-support-agent/.difyignore create mode 100644 fr3on/slack-support-agent/LICENSE create mode 100644 fr3on/slack-support-agent/PRIVACY.md create mode 100644 fr3on/slack-support-agent/README.md create mode 100644 fr3on/slack-support-agent/_assets/icon.svg create mode 100644 fr3on/slack-support-agent/endpoints/slack.py create mode 100644 fr3on/slack-support-agent/endpoints/slack.yaml create mode 100644 fr3on/slack-support-agent/group/slack.yaml create mode 100644 fr3on/slack-support-agent/main.py create mode 100644 fr3on/slack-support-agent/manifest.yaml create mode 100644 fr3on/slack-support-agent/requirements.txt create mode 100644 fr3on/slack-support-agent/slack-support-agent-0.0.1.difypkg diff --git a/fr3on/slack-support-agent/.difyignore b/fr3on/slack-support-agent/.difyignore new file mode 100644 index 000000000..bd138e822 --- /dev/null +++ b/fr3on/slack-support-agent/.difyignore @@ -0,0 +1,7 @@ +.env +.vscode/ +__pycache__/ +*.pyc +.DS_Store +.git/ +.gitignore diff --git a/fr3on/slack-support-agent/LICENSE b/fr3on/slack-support-agent/LICENSE new file mode 100644 index 000000000..5514244ee --- /dev/null +++ b/fr3on/slack-support-agent/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 fr3on + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/fr3on/slack-support-agent/PRIVACY.md b/fr3on/slack-support-agent/PRIVACY.md new file mode 100644 index 000000000..86907436c --- /dev/null +++ b/fr3on/slack-support-agent/PRIVACY.md @@ -0,0 +1,31 @@ +## Privacy Policy for Slack Support Agent + +This plugin acts as an extension to communicate with the Slack API and facilitate automated replies in Slack threads. + +--- + +**Data Collection** + +To function properly, this plugin requires you to provide Slack credentials (e.g., a Bot Token) when configuring the plugin in Dify. The following data is accessed or processed during normal operation: + +- **Slack credentials**: Necessary tokens to authenticate and access Slack’s API. +- **Slack messages and thread conversation history**: Depending on configuration, the plugin may read and process messages within Slack threads to provide replies. +- **Slack user information**: The plugin may retrieve basic user data (such as user IDs, display names) in order to mention or reference users within threads. + +--- + +**Data Usage** + +- **Credentials**: Slack tokens are used solely for making authorized requests to the Slack API. +- **Message Content**: The plugin processes thread conversation text so it can provide messages to your Dify runtime environment. +- **User Data**: Information about users in a Slack thread is used only to format mentions (`<@ID>`) or retrieve user context. +- **Data Retention**: To provide conversation context and avoid Slack's rate limits, the plugin caches recent thread/DM messages (channel, timestamps, sender ID, and text) in Dify's plugin storage for up to 24 hours, after which they expire automatically. It also stores the Dify `conversation_id` for each Slack thread or direct message so follow-up messages continue the same conversation; this value is retained until the thread/DM is superseded by a new one. This data stays within your own Dify plugin storage and is not shared with any third party beyond the Slack API calls and the linked Dify app you configure. + +--- + +**Third-Party Services / Self-Hosting** + +- The plugin communicates directly with Slack’s API, which is governed by Slack’s own privacy policy. For more information, refer to: + [https://slack.com/legal](https://slack.com/legal) +- Configuration of Slack (workspace settings, channels, permissions, etc.) is the sole responsibility of the user. +- This plugin is not affiliated with Slack, Inc. diff --git a/fr3on/slack-support-agent/README.md b/fr3on/slack-support-agent/README.md new file mode 100644 index 000000000..fef050dd9 --- /dev/null +++ b/fr3on/slack-support-agent/README.md @@ -0,0 +1,103 @@ +## Slack Support Agent + +**Version:** 0.0.1 +**Type:** extension + +Connects Slack to a Dify chat app: reply within Slack threads and to direct messages sent straight to the bot, with conversation history, user info, file uploads, and `mrkdwn` formatting handled for you. + +### Contents + +- [Features](#features) +- [Settings](#settings) +- [Install](#install) +- [Setup](#setup) +- [Usage in your Dify app](#usage-in-your-dify-app) +- [Support](#support) + +### Features + +- **Threads** - reply within Slack threads, with an optional setting to also post the first reply to the channel +- **Direct messages** - reply to DMs sent straight to the bot, with the conversation continuing turn-to-turn just like Dify's own chat UI, not just @mentions in channels +- **File uploads** - Slack file attachments are downloaded and passed to the linked app automatically +- **`mrkdwn` formatting** - the app's Markdown answer is converted to Slack's `mrkdwn` and split across multiple messages if it exceeds Slack's block text limit +- **Context, your way** - the linked app receives thread/DM conversation history and a resolved user list; how much history it gets is configurable (see [Settings](#settings)) +- **Channel restriction** - optionally lock the bot to a single Slack channel + +> [!NOTE] +> The app also receives `channel_id` and `thread_ts` as inputs. These are for advanced use cases - e.g. a linked plugin posting follow-up messages to a specific channel or thread - and most setups won't need them. + +### Settings + +| Setting | Required | Description | +| --- | --- | --- | +| App | Yes | The Dify chat app that answers Slack messages. | +| Bot Token | Yes | Your Slack bot's `xoxb-...` token. | +| Allowed Channel | No | Restrict the bot to one channel, in `#channel` format. Leave blank to allow all channels. Doesn't apply to DMs. | +| Thread Context Sent to App | No | How much thread history to send with each reply: **Full thread** (default), **Root message only**, or **Message right before the mention**. | +| Broadcast First Reply | No | Also post the bot's first reply in a thread to the channel itself, not just the thread. | +| Allow Retry | No | Process Slack's automatic retries of a webhook delivery instead of ignoring them. Off by default to avoid duplicate replies. | +| Skip App Timeout Error Notifications | No | If the linked app times out, stay silent instead of posting an error message to Slack. | + +### Install + +To install this plugin, specify the following GitHub repository when selecting "Install Plugin": + +https://github.com/fr3on/slack-support-agent + +### Setup + +Follow the same setup procedure as the official SlackBot plugin, but with this plugin's scopes and events. + +**Bot token scopes:** + +```text +app_mentions:read, users:read, channels:history, groups:history, im:history, chat:write, groups:write, channels:read, +groups:read, im:read, files:read +``` + +**Subscribe to bot events:** + +| Event | Purpose | +| --- | --- | +| `app_mention` | Reply when someone @-mentions the bot in a channel | +| `message.im` | Reply to direct messages sent to the bot | +| `message.channels` | Cache messages in public channel threads | +| `message.groups` | Cache messages in private channel threads | + +The `message.channels`/`message.groups` events feed a caching layer that keeps thread context available without repeatedly hitting Slack's thread-history endpoint, which is rate-limited to about 1 request per minute. + +For details on setting up the official SlackBot plugin, see: + +https://github.com/langgenius/dify-official-plugins/blob/main/extensions/slack_bot/README.md + +### Usage in your Dify app + +In the start node of the chat flow app you link to this plugin, add these input fields to receive Slack context: + +| Input field | Field type | +| --- | --- | +| `thread_history` | Paragraph, max length e.g. 65535 | +| `thread_users` | Paragraph, max length e.g. 65535 | +| `files` | File List | +| `thread_ts` | Short Text, max length e.g. 48 | +| `channel_id` | Short Text, max length e.g. 48 | + +Example prompt (LLM node in the chat flow app): + +```text +You are an assistant on Slack who answers user questions. +Refer to the recent conversation history and provide an appropriate response. + +If you need to mention a specific user, refer to the user list and mention them in the format `<@ID>`. + +# Recent conversation history +Start.thread_history +# User list +Start.thread_users +``` + +### Support + +- Source repository: https://github.com/fr3on/slack-support-agent +- Issues / questions: https://github.com/fr3on/slack-support-agent/issues +- Contact: [@fr3on](https://github.com/fr3on) on GitHub diff --git a/fr3on/slack-support-agent/_assets/icon.svg b/fr3on/slack-support-agent/_assets/icon.svg new file mode 100644 index 000000000..40a916ccd --- /dev/null +++ b/fr3on/slack-support-agent/_assets/icon.svg @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/fr3on/slack-support-agent/endpoints/slack.py b/fr3on/slack-support-agent/endpoints/slack.py new file mode 100644 index 000000000..c60d9ea01 --- /dev/null +++ b/fr3on/slack-support-agent/endpoints/slack.py @@ -0,0 +1,698 @@ +"""Slack endpoint for the Support Agent plugin. + +Receives Slack Events API webhooks (channel @mentions, direct messages, and +passive channel/group messages used to build thread context), forwards the +conversation to a linked Dify chat app, and posts the answer back to Slack. +""" + +import json +import logging +import re +import time +import traceback +from dataclasses import dataclass +from typing import Any, Dict, List, Mapping, Optional + +import requests +from dify_plugin import Endpoint +from slack_sdk import WebClient +from slack_sdk.errors import SlackApiError +from werkzeug import Request, Response + +logger = logging.getLogger(__name__) + +MENTION_PATTERN = re.compile(r"<@([A-Za-z0-9]+)>") +LOOSE_MENTION_PATTERN = re.compile(r"<@([^>]+)>") +SLACK_BLOCK_TEXT_LIMIT = 3000 # https://api.slack.com/reference/block-kit/composition-objects#text__fields +DM_CONVERSATION_ANCHOR = "dm-main" + + +class SlackMarkdownConverter: + """Converts Markdown text into Slack's mrkdwn format. + + ref: https://github.com/fla9ua/markdown_to_mrkdwn + """ + + def __init__(self, encoding: str = "utf-8"): + self.encoding = encoding + self.in_code_block = False + self.table_replacements: Dict[str, str] = {} + self.patterns: List[tuple] = [ + (re.compile(r"^(\s*)- \[([ ])\] (.+)", re.MULTILINE), r"\1• ☐ \3"), # Unchecked task list + (re.compile(r"^(\s*)- \[([xX])\] (.+)", re.MULTILINE), r"\1• ☑ \3"), # Checked task list + (re.compile(r"^(\s*)- (.+)", re.MULTILINE), r"\1• \2"), # Unordered list + (re.compile(r"^(\s*)(\d+)\. (.+)", re.MULTILINE), r"\1\2. \3"), # Ordered list + (re.compile(r"!\[.*?\]\((.+?)\)", re.MULTILINE), r"<\1>"), # Images to URL + (re.compile(r"(?"), # Links + (re.compile(r"`(.+?)`", re.MULTILINE), r"`\1`"), # Inline code + (re.compile(r"^> (.+)", re.MULTILINE), r"> \1"), # Blockquote + (re.compile(r"^(---|\*\*\*|___)$", re.MULTILINE), r"──────────"), # Horizontal line + (re.compile(r"~~(.+?)~~", re.MULTILINE), r"~\1~"), # Strikethrough + ] + self.triple_start = "%%BOLDITALIC_START%%" + self.triple_end = "%%BOLDITALIC_END%%" + + def convert(self, markdown: str) -> str: + if not markdown: + return "" + + try: + markdown = markdown.strip() + self.table_replacements = {} + markdown = self._convert_tables(markdown) + + converted_lines = [self._convert_line(line) for line in markdown.split("\n")] + result = "\n".join(converted_lines) + + for placeholder, table in self.table_replacements.items(): + result = result.replace(placeholder, table) + + return result.encode(self.encoding).decode(self.encoding) + except Exception: + return markdown + + def _convert_tables(self, markdown: str) -> str: + table_pattern = re.compile( + r"^\|(.+)\|\s*$\n^\|[-:| ]+\|\s*$(\n^\|.+\|\s*$)*", re.MULTILINE + ) + + def convert_table(match: re.Match) -> str: + original_table = match.group(0) + table_lines = original_table.strip().split("\n") + header_line = table_lines[0] + data_lines = table_lines[2:] if len(table_lines) > 2 else [] + + headers = [cell.strip() for cell in header_line.strip("|").split("|")] + rows = [ + [cell.strip() for cell in line.strip("|").split("|")] + for line in data_lines + ] + + result = [" | ".join(f"*{header}*" for header in headers)] + result.extend(" | ".join(row) for row in rows) + + placeholder = f"%%TABLE_PLACEHOLDER_{hash(original_table)}%%" + self.table_replacements[placeholder] = "\n".join(result) + return placeholder + + return table_pattern.sub(convert_table, markdown) + + def _convert_line(self, line: str) -> str: + if line.startswith("%%TABLE_PLACEHOLDER_") and line.endswith("%%"): + return line + + code_block_match = re.match(r"^```(\w*)$", line) + if code_block_match: + language = code_block_match.group(1) + self.in_code_block = not self.in_code_block + return f"```{language}" if self.in_code_block and language else "```" + + if self.in_code_block: + return line + + line = re.sub( + r"(? Dict[str, Any]: + # `ts` is only used internally to filter by context_scope - it's + # never sent to the linked app. + return { + "role": self.role, + "participant_id": self.participant_id, + "content": self.content, + "participant_name": self.participant_name, + } + + +@dataclass +class RoutingContext: + """Where an incoming Slack event routes to, and how the bot should reply. + + For a channel @mention, or a DM reply the user explicitly threaded, + `raw_thread_ts` is a real Slack thread timestamp and every property below + is simply that value (or the event's own ts for a brand-new thread). + + For a plain DM message - the common case, since Slack doesn't set + thread_ts unless the user threads a reply - there's no natural thread to + anchor to. Anchoring each message to its own ts would start a brand-new + Dify conversation on every single message, so instead the whole DM + channel shares one fixed conversation anchor, and replies post as normal + top-level DM messages rather than a threaded reply under each message. + """ + + channel: str + event_ts: Optional[str] + is_dm: bool + raw_thread_ts: Optional[str] + + @property + def _is_freeform_dm(self) -> bool: + return self.is_dm and not self.raw_thread_ts + + @property + def conversation_ts(self) -> str: + """Anchors our cache/conversation storage keys.""" + if self._is_freeform_dm: + return DM_CONVERSATION_ANCHOR + return self.raw_thread_ts or self.event_ts + + @property + def reply_thread_ts(self) -> Optional[str]: + """Value passed to Slack's chat_postMessage/conversations_replies.""" + if self._is_freeform_dm: + return None + return self.raw_thread_ts or self.event_ts + + @property + def slack_thread_ts(self) -> Optional[str]: + """The real Slack timestamp handed to the linked Dify app as input, + for use by downstream plugins (e.g. Slack Post) - never our internal + DM conversation anchor.""" + return self.raw_thread_ts or self.event_ts + + +class SlackEndpoint(Endpoint): + CACHE_PREFIX = "thread-cache" + CONVERSATION_PREFIX = "slack" + CACHE_DURATION = 60 * 60 * 24 # 1 day + + # ---------------------------------------------------------------- cache + + def _cache_key(self, channel: str, conversation_ts: str) -> str: + return f"{self.CACHE_PREFIX}-{channel}-{conversation_ts}" + + def _conversation_key(self, channel: str, conversation_ts: str) -> str: + return f"{self.CONVERSATION_PREFIX}-{channel}-{conversation_ts}" + + def _load_cached_history(self, channel: str, conversation_ts: str) -> List[Dict[str, Any]]: + key = self._cache_key(channel, conversation_ts) + try: + raw = self.session.storage.get(key) + except Exception: + return [] + if not raw: + return [] + + try: + all_messages = json.loads(raw.decode("utf-8")).get("messages", []) + except Exception: + return [] + + now = time.time() + active = [m for m in all_messages if now - m.get("saved_at", now) < self.CACHE_DURATION] + if len(active) != len(all_messages): + self._write_cache(channel, conversation_ts, active) + return active + + def _write_cache(self, channel: str, conversation_ts: str, messages: List[Dict[str, Any]]) -> None: + try: + self.session.storage.set( + self._cache_key(channel, conversation_ts), + json.dumps({"messages": messages}).encode("utf-8"), + ) + except Exception: + pass + + def _append_thread_message(self, channel: str, conversation_ts: str, message: Mapping) -> None: + messages = self._load_cached_history(channel, conversation_ts) + msg = dict(message) + msg["saved_at"] = time.time() + messages.append(msg) + self._write_cache(channel, conversation_ts, messages) + + def _get_conversation_id(self, channel: str, conversation_ts: str) -> Optional[str]: + try: + raw = self.session.storage.get(self._conversation_key(channel, conversation_ts)) + except Exception: + return None + return raw.decode("utf-8") if raw else None + + def _set_conversation_id(self, channel: str, conversation_ts: str, conversation_id: str) -> None: + try: + self.session.storage.set( + self._conversation_key(channel, conversation_ts), + conversation_id.encode("utf-8"), + ) + except Exception: + pass + + # -------------------------------------------------------------- routing + + def _invoke(self, r: Request, values: Mapping, settings: Mapping) -> Response: + if self._is_ignorable_retry(r, settings): + return Response(status=200, response="ok") + + data = r.get_json(silent=True) or {} + + if data.get("type") == "url_verification": + return Response( + response=json.dumps({"challenge": data.get("challenge")}), + status=200, + content_type="application/json", + ) + + if data.get("type") != "event_callback": + return Response(status=200, response="ok") + + event = data.get("event") or {} + is_dm = self._is_dm_message(event) + + if event.get("type") == "app_mention" or is_dm: + return self._handle_reply(event, settings, is_dm) + if event.get("type") == "message": + return self._handle_passive_message(event) + return Response(status=200, response="ok") + + @staticmethod + def _is_ignorable_retry(r: Request, settings: Mapping) -> bool: + if settings.get("allow_retry"): + return False + retry_num = r.headers.get("X-Slack-Retry-Num") + return bool( + r.headers.get("X-Slack-Retry-Reason") == "http_timeout" + or (retry_num is not None and int(retry_num) > 0) + ) + + @staticmethod + def _is_dm_message(event: Mapping) -> bool: + """Slack sends type="message" with channel_type="im" for a DM (no + app_mention fires in a DM, since there's no one else to @-mention). + Guard against bot_id/subtype so we never reply to our own posts or to + edits/deletes - either would otherwise create a reply loop.""" + return bool( + event.get("type") == "message" + and event.get("channel_type") == "im" + and not event.get("bot_id") + and not event.get("subtype") + and event.get("text") + ) + + @staticmethod + def _event_to_cache_entry(event: Mapping) -> Dict[str, Any]: + return { + "ts": event.get("ts"), + "text": event.get("text", ""), + "user": event.get("user"), + "bot_id": event.get("bot_id"), + } + + # ------------------------------------------------- passive caching path + + def _handle_passive_message(self, event: Mapping) -> Response: + """Messages that aren't a mention or a DM (e.g. other people replying + in a channel thread the bot is already tracking) are cached silently, + without invoking the linked app, so full thread context is available + next time the bot is mentioned in that thread.""" + channel = event.get("channel", "") + thread_ts = event.get("thread_ts") or event.get("ts") + if self._is_thread_recognized(channel, thread_ts): + self._append_thread_message(channel, thread_ts, self._event_to_cache_entry(event)) + return Response(status=200, response="ok") + + def _is_thread_recognized(self, channel: str, thread_ts: str) -> bool: + for key in ( + self._conversation_key(channel, thread_ts), + self._cache_key(channel, thread_ts), + ): + try: + if self.session.storage.get(key): + return True + except Exception: + pass + return False + + # ----------------------------------------------------- mention -> reply + + def _handle_reply(self, event: Mapping, settings: Mapping, is_dm: bool) -> Response: + ctx = RoutingContext( + channel=event.get("channel", ""), + event_ts=event.get("ts"), + is_dm=is_dm, + raw_thread_ts=event.get("thread_ts"), + ) + client = WebClient(token=settings.get("bot_token")) + message_text = self._extract_message_text(event) + + self._append_thread_message(ctx.channel, ctx.conversation_ts, self._event_to_cache_entry(event)) + + blocked = self._enforce_allowed_channel(client, ctx, settings, is_dm) + if blocked is not None: + return blocked + + try: + return self._respond(client, ctx, settings, event, message_text) + except Exception as e: + return self._handle_invoke_error(client, ctx, settings, e) + + @staticmethod + def _extract_message_text(event: Mapping) -> str: + text = event.get("text", "") + if event.get("type") == "app_mention": + # Remove the leading bot mention, e.g. "<@U123> what's up" -> "what's up". + # DMs carry no leading mention, so nothing to strip there. + return re.sub(r"^<@[^>]+>\s*", "", text) + return text + + def _enforce_allowed_channel( + self, client: WebClient, ctx: RoutingContext, settings: Mapping, is_dm: bool + ) -> Optional[Response]: + """Returns a short-circuit Response if this channel isn't allowed (or + the check itself failed), else None to continue. DMs have no channel + name to compare against - the restriction is a channel-scoping + feature, so it's skipped for DMs entirely.""" + allowed_channel = settings.get("allowed_channel", "").strip() + if not allowed_channel or is_dm: + return None + + try: + channel_info = client.conversations_info(channel=ctx.channel) + actual_channel = f"#{channel_info['channel']['name']}" + except SlackApiError as e: + logger.warning("Error getting channel info: %s", e) + self._safe_post(client, ctx, f"Failed to retrieve channel info. SlackApiError: {e}") + return Response(status=200, response="ok", content_type="text/plain") + except Exception as e: + logger.warning("Unexpected error getting channel info: %s", e) + self._safe_post( + client, ctx, f"An unexpected error occurred while retrieving channel info. Error: {e}" + ) + return Response(status=200, response="ok", content_type="text/plain") + + if actual_channel == allowed_channel: + return None + + self._safe_post(client, ctx, f"Current channel: {actual_channel} is not allowed.") + return Response(status=200, response="ok", content_type="text/plain") + + @staticmethod + def _safe_post(client: WebClient, ctx: RoutingContext, text: str) -> None: + try: + client.chat_postMessage(channel=ctx.channel, thread_ts=ctx.reply_thread_ts, text=text) + except SlackApiError: + pass + + # ------------------------------------------------------- app invocation + + def _respond( + self, client: WebClient, ctx: RoutingContext, settings: Mapping, event: Mapping, message_text: str + ) -> Response: + conversation_id = self._get_conversation_id(ctx.channel, ctx.conversation_ts) + + raw_messages = self._fetch_thread_messages(client, ctx) + user_display_names = self._resolve_display_names(client, raw_messages) + thread_history, is_first_message = self._build_thread_history(raw_messages, user_display_names) + thread_history = self._apply_context_scope( + thread_history, settings.get("context_scope", "full_thread"), ctx.event_ts + ) + + uploaded_files = self._upload_slack_files( + client, ctx, settings.get("bot_token"), event.get("files", []) + ) + + invoke_params: Dict[str, Any] = { + "app_id": settings["app"]["app_id"], + "query": self._substitute_mentions(message_text, user_display_names), + "inputs": self._build_invoke_inputs(ctx, thread_history, user_display_names, uploaded_files), + "response_mode": "blocking", + } + if conversation_id is not None: + invoke_params["conversation_id"] = conversation_id + + response = self.session.app.chat.invoke(**invoke_params) + answer = response.get("answer") + + new_conversation_id = response.get("conversation_id") + if new_conversation_id: + self._set_conversation_id(ctx.channel, ctx.conversation_ts, new_conversation_id) + + try: + return self._post_answer(client, ctx, answer, settings, is_first_message) + except SlackApiError as e: + return Response( + status=200, response=f"Error sending message to Slack: {e}", content_type="text/plain" + ) + + def _fetch_thread_messages(self, client: WebClient, ctx: RoutingContext) -> List[Dict[str, Any]]: + """Raw Slack messages for this thread/DM, from cache if present, else + fetched from Slack. Only falls back to Slack's API when we have a + real thread ts - the synthetic DM anchor isn't one, and the cache is + never empty here anyway since the triggering message was already + appended to it in `_handle_reply`.""" + messages = self._load_cached_history(ctx.channel, ctx.conversation_ts) + if messages or not ctx.reply_thread_ts: + return messages + + messages = self._fetch_replies_with_retry(client, ctx) + for m in messages: + self._append_thread_message(ctx.channel, ctx.conversation_ts, m) + return messages + + def _fetch_replies_with_retry(self, client: WebClient, ctx: RoutingContext) -> List[Dict[str, Any]]: + try: + return client.conversations_replies(channel=ctx.channel, ts=ctx.reply_thread_ts).get("messages", []) + except SlackApiError as e: + if e.response.get("error") != "ratelimited": + logger.warning("Error getting thread history: %s", e) + return [] + + retry_after = int(e.response.get("headers", {}).get("Retry-After", 60)) + self._safe_post( + client, ctx, f"Rate limit reached when retrieving thread. Retrying in {retry_after} seconds..." + ) + time.sleep(retry_after) + try: + return client.conversations_replies(channel=ctx.channel, ts=ctx.reply_thread_ts).get("messages", []) + except SlackApiError as retry_error: + logger.warning("Error getting thread history after retry: %s", retry_error) + return [] + + def _resolve_display_names(self, client: WebClient, messages: List[Dict[str, Any]]) -> Dict[str, str]: + user_ids: List[str] = [] + for msg in messages: + user_id = msg.get("user", "unknown") + if user_id != "unknown" and user_id not in user_ids: + user_ids.append(user_id) + for mentioned_id in LOOSE_MENTION_PATTERN.findall(msg.get("text") or ""): + if mentioned_id not in user_ids: + user_ids.append(mentioned_id) + + display_names: Dict[str, str] = {} + try: + for user_id in user_ids: + info = client.users_info(user=user_id).get("user", {}) + name, real_name = info.get("name", ""), info.get("real_name", "") + display_names[user_id] = f"{real_name} ({name})" if name else real_name + except SlackApiError as e: + logger.warning("Error getting user info: %s", e) + return display_names + + def _build_thread_history( + self, messages: List[Dict[str, Any]], user_display_names: Dict[str, str] + ) -> tuple: + thread_history = [ + ThreadMessage( + role="assistant" if msg.get("bot_id") else "user", + participant_id=(participant_id := msg.get("user", "unknown")), + content=self._substitute_mentions(msg.get("text", ""), user_display_names), + ts=msg.get("ts"), + participant_name=user_display_names.get(participant_id, "unknown"), + ) + for msg in messages + ] + # Decided from the untrimmed history, before _apply_context_scope + # shrinks it down to 0-1 items regardless of the thread's real length. + is_first_message = len(thread_history) == 1 + return thread_history, is_first_message + + @staticmethod + def _apply_context_scope( + thread_history: List[ThreadMessage], scope: str, trigger_ts: Optional[str] + ) -> List[ThreadMessage]: + """Trims thread_history down to the configured scope. The triggering + message itself is excluded from the trimmed scopes since it's already + sent separately as the query.""" + if scope == "full_thread": + return thread_history + + without_trigger = [m for m in thread_history if m.ts != trigger_ts] + if scope == "parent_message": + return without_trigger[:1] + if scope == "last_message": + return without_trigger[-1:] + return thread_history + + @staticmethod + def _substitute_mentions(text: str, user_display_names: Mapping[str, str]) -> str: + def replace(match: re.Match) -> str: + user_id = match.group(1) + return f"@{user_display_names[user_id]}" if user_id in user_display_names else match.group(0) + + return MENTION_PATTERN.sub(replace, text) + + def _upload_slack_files( + self, client: WebClient, ctx: RoutingContext, token: str, slack_files: List[Dict[str, Any]] + ) -> List[Any]: + uploaded = [] + for f in slack_files: + file_name = f.get("name") + file_url = f.get("url_private_download") + mimetype = f.get("mimetype", "application/octet-stream") + if not file_url or not file_name: + continue + + resp = requests.get(file_url, headers={"Authorization": f"Bearer {token}"}) + if resp.status_code != 200: + logger.warning( + "Failed to download file from Slack: %s, status code=%s", file_name, resp.status_code + ) + continue + + try: + storage_file = self.session.file.upload(filename=file_name, content=resp.content, mimetype=mimetype) + if storage_file: + uploaded.append(storage_file) + except Exception as e: + self._safe_post( + client, + ctx, + f"Error uploading file: {e}\n\n" + "This may be caused by an unconfigured `FILES_URL` in your `dify/docker/.env` .\n" + "Please set `FILES_URL` properly and restart( `docker compose down && docker compose up -d` ) " + "your Dify environment, then try again.", + ) + logger.warning("Error uploading file via session.file.upload: %s", e) + return uploaded + + @staticmethod + def _build_invoke_inputs( + ctx: RoutingContext, + thread_history: List[ThreadMessage], + user_display_names: Dict[str, str], + uploaded_files: List[Any], + ) -> Dict[str, Any]: + inputs: Dict[str, Any] = { + "thread_history": json.dumps([m.to_dict() for m in thread_history], indent=4, ensure_ascii=False), + "thread_users": json.dumps(user_display_names, indent=4, ensure_ascii=False), + "thread_ts": ctx.slack_thread_ts, + "channel_id": ctx.channel, + } + if uploaded_files: + inputs["files"] = [ + {"type": f.type, "transfer_method": "remote_url", "url": f.preview_url} + for f in uploaded_files + ] + return inputs + + def _handle_invoke_error( + self, client: WebClient, ctx: RoutingContext, settings: Mapping, error: Exception + ) -> Response: + err_msg = str(error) + err_trace = traceback.format_exc() + + skip_timeout_error = settings.get("skip_timeout_error", False) + if skip_timeout_error and "invocation exited without response" in err_msg.lower(): + return Response(status=200, response="ok", content_type="text/plain") + + self._safe_post( + client, ctx, f"Sorry, I'm having trouble processing your request. Please try again later. Error: {err_msg}" + ) + return Response( + status=200, response=f"An error occurred: {err_msg}\n{err_trace}", content_type="text/plain" + ) + + # ------------------------------------------------------------- replying + + def _post_answer( + self, client: WebClient, ctx: RoutingContext, answer: str, settings: Mapping, is_first_message: bool + ) -> Response: + converted_answer = SlackMarkdownConverter().convert(answer) + chunks = self._split_into_chunks(converted_answer, SLACK_BLOCK_TEXT_LIMIT) + reply_broadcast = settings.get("first_reply_broadcast", False) and is_first_message + + for i, chunk in enumerate(chunks): + resp = client.chat_postMessage( + channel=ctx.channel, + text=chunk, # fallback text + thread_ts=ctx.reply_thread_ts, + blocks=[{"type": "section", "text": {"type": "mrkdwn", "text": chunk}}], + # Only broadcast the first chunk - broadcasting every chunk + # would flood the channel outside the thread. + reply_broadcast=reply_broadcast if i == 0 else False, + ) + self._append_thread_message( + ctx.channel, + ctx.conversation_ts, + { + "ts": resp.get("ts"), + "text": chunk, + "user": resp.get("message", {}).get("user"), + "bot_id": resp.get("message", {}).get("bot_id"), + }, + ) + + return Response(status=200, response="ok", content_type="text/plain") + + @staticmethod + def _split_into_chunks(text: str, max_len: int) -> List[str]: + """Greedily packs lines into chunks up to `max_len` characters, + splitting any single line that's longer than max_len on its own.""" + if len(text) <= max_len: + return [text] + + def pieces(line: str): + if len(line) <= max_len: + yield line + else: + for i in range(0, len(line), max_len): + yield line[i : i + max_len] + + chunks: List[str] = [] + current = "" + for line in text.split("\n"): + for piece in pieces(line): + added_len = len(piece) + (1 if current else 0) + if len(current) + added_len <= max_len: + current = f"{current}\n{piece}" if current else piece + else: + if current: + chunks.append(current) + current = piece + if current: + chunks.append(current) + return chunks diff --git a/fr3on/slack-support-agent/endpoints/slack.yaml b/fr3on/slack-support-agent/endpoints/slack.yaml new file mode 100644 index 000000000..f1ab36180 --- /dev/null +++ b/fr3on/slack-support-agent/endpoints/slack.yaml @@ -0,0 +1,5 @@ +path: "/" +method: "POST" +extra: + python: + source: "endpoints/slack.py" diff --git a/fr3on/slack-support-agent/group/slack.yaml b/fr3on/slack-support-agent/group/slack.yaml new file mode 100644 index 000000000..16015b480 --- /dev/null +++ b/fr3on/slack-support-agent/group/slack.yaml @@ -0,0 +1,98 @@ +settings: + - name: bot_token + type: secret-input + required: true + label: + en_US: Bot Token + zh_Hans: Bot Token + pt_BR: Token do Bot + ja_JP: Bot Token + placeholder: + en_US: Please input your Bot Token + zh_Hans: 请输入你的 Bot Token + pt_BR: Por favor, insira seu Token do Bot + ja_JP: ボットトークンを入力してください + - name: first_reply_broadcast + type: boolean + required: false + label: + en_US: Broadcast First Reply + zh_Hans: 广播首次回复 + pt_BR: Transmitir Primeira Resposta + ja_JP: 初回のレスポンスをチャンネルにも送信する + default: false + - name: allow_retry + type: boolean + required: false + label: + en_US: Allow Retry + zh_Hans: 允许重试 + pt_BR: Permitir Retentativas + ja_JP: 再試行を許可 + default: false + - name: skip_timeout_error + type: boolean + required: false + label: + en_US: Skip App Timeout Error Notifications + zh_Hans: 跳过应用程序超时错误通知 + pt_BR: Pular Notificações de Erro de Timeout do Aplicativo + ja_JP: アプリのタイムアウトエラー通知をスキップ + default: false + - name: context_scope + type: select + required: false + label: + en_US: Thread Context Sent to App + zh_Hans: 发送给应用的线程上下文 + pt_BR: Contexto da Thread Enviado ao App + ja_JP: アプリに送信するスレッドの文脈 + options: + - value: full_thread + label: + en_US: Full thread + zh_Hans: 完整线程 + pt_BR: Thread completa + ja_JP: スレッド全体 + - value: parent_message + label: + en_US: Root message only + zh_Hans: 仅根消息 + pt_BR: Apenas mensagem raiz + ja_JP: 起点メッセージのみ + - value: last_message + label: + en_US: Message right before the mention + zh_Hans: 提及前的最后一条消息 + pt_BR: Mensagem anterior à menção + ja_JP: メンション直前のメッセージのみ + default: full_thread + - name: allowed_channel + type: text-input + required: false + label: + en_US: "Allowed Channel (in #channel format)" + zh_Hans: "允许使用的频道(#channel 格式)" + pt_BR: "Canal Permitido (no formato #channel)" + ja_JP: "利用を許可するチャンネル(#channel 形式)" + placeholder: + en_US: "Leave blank to allow all" + zh_Hans: "留空则允许所有频道" + pt_BR: "Deixe em branco para permitir todos" + ja_JP: "未入力の場合は全てのチャンネルで利用可能" + default: "" + - name: app + type: app-selector + required: true + label: + en_US: App + zh_Hans: 应用 + pt_BR: App + ja_JP: アプリ + placeholder: + en_US: the app you want to use to answer Slack messages + zh_Hans: 你想要用来回答 Slack 消息的应用 + pt_BR: o app que você deseja usar para responder mensagens do Slack + ja_JP: あなたが Slack メッセージに回答するために使用するアプリ +endpoints: + - endpoints/slack.yaml diff --git a/fr3on/slack-support-agent/main.py b/fr3on/slack-support-agent/main.py new file mode 100644 index 000000000..f2bfcdfc0 --- /dev/null +++ b/fr3on/slack-support-agent/main.py @@ -0,0 +1,6 @@ +from dify_plugin import Plugin, DifyPluginEnv + +plugin = Plugin(DifyPluginEnv()) + +if __name__ == '__main__': + plugin.run() diff --git a/fr3on/slack-support-agent/manifest.yaml b/fr3on/slack-support-agent/manifest.yaml new file mode 100644 index 000000000..3e7632bda --- /dev/null +++ b/fr3on/slack-support-agent/manifest.yaml @@ -0,0 +1,51 @@ +version: 0.0.1 +type: plugin +author: fr3on +name: slack-support-agent +label: + en_US: Slack Support Agent + ja_JP: Slack Support Agent + zh_Hans: Slack Support Agent + pt_BR: Slack Support Agent +description: + en_US: "Connects Slack to a Dify agent for thread replies (optionally posting the first reply to the channel) and direct messages, with mrkdwn formatting, thread/DM conversation history and user list, configurable context scope, and optional usage channel restriction." + ja_JP: "スレッド返信(初回返信をチャンネルにも投稿可)とダイレクトメッセージに対応したSlack向けDifyエージェント連携プラグイン。mrkdwn対応、スレッド/DM履歴・ユーザーリスト参照、文脈範囲の設定、任意の利用チャンネル制限に対応。" + zh_Hans: "将 Slack 连接到 Dify 智能体的插件,支持线程回复(可选将首次回复发送至频道)和私信,支持 mrkdwn 格式、线程/私信历史和用户列表、可配置的上下文范围,并可选限制使用频道。" + pt_BR: "Conecta o Slack a um agente Dify para respostas em thread (opcionalmente postando a primeira resposta no canal) e mensagens diretas, com formatação mrkdwn, histórico de conversa em thread/DM e lista de usuários, escopo de contexto configurável, e restrição opcional de canais de uso." +icon: icon.svg +tags: ['productivity','social'] +resource: + memory: 536870912 + permission: + tool: + enabled: true + model: + enabled: true + llm: true + text_embedding: false + rerank: false + tts: false + speech2text: false + moderation: false + endpoint: + enabled: true + app: + enabled: true + storage: + enabled: true + size: 1048576 +plugins: + endpoints: + - group/slack.yaml +meta: + version: 0.0.1 + arch: + - amd64 + - arm64 + runner: + language: python + version: "3.12" + entrypoint: main +created_at: 2026-09-09T00:00:00Z +privacy: PRIVACY.md +verified: false diff --git a/fr3on/slack-support-agent/requirements.txt b/fr3on/slack-support-agent/requirements.txt new file mode 100644 index 000000000..98a46f9d8 --- /dev/null +++ b/fr3on/slack-support-agent/requirements.txt @@ -0,0 +1,4 @@ +dify_plugin==0.2.4 +Werkzeug==3.0.6 +slack_sdk==3.35.0 +requests==2.32.3 diff --git a/fr3on/slack-support-agent/slack-support-agent-0.0.1.difypkg b/fr3on/slack-support-agent/slack-support-agent-0.0.1.difypkg new file mode 100644 index 0000000000000000000000000000000000000000..0fcd4f76f7acfb31e25a6ee46e9347583e8de84e GIT binary patch literal 16435 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 Date: Fri, 11 Sep 2026 14:00:11 +0300 Subject: [PATCH 2/4] Submit only the packaged .difypkg --- fr3on/slack-support-agent/.difyignore | 7 - fr3on/slack-support-agent/LICENSE | 21 - fr3on/slack-support-agent/PRIVACY.md | 31 - fr3on/slack-support-agent/README.md | 103 --- fr3on/slack-support-agent/_assets/icon.svg | 27 - fr3on/slack-support-agent/endpoints/slack.py | 698 ------------------ .../slack-support-agent/endpoints/slack.yaml | 5 - fr3on/slack-support-agent/group/slack.yaml | 98 --- fr3on/slack-support-agent/main.py | 6 - fr3on/slack-support-agent/manifest.yaml | 51 -- fr3on/slack-support-agent/requirements.txt | 4 - 11 files changed, 1051 deletions(-) delete mode 100644 fr3on/slack-support-agent/.difyignore delete mode 100644 fr3on/slack-support-agent/LICENSE delete mode 100644 fr3on/slack-support-agent/PRIVACY.md delete mode 100644 fr3on/slack-support-agent/README.md delete mode 100644 fr3on/slack-support-agent/_assets/icon.svg delete mode 100644 fr3on/slack-support-agent/endpoints/slack.py delete mode 100644 fr3on/slack-support-agent/endpoints/slack.yaml delete mode 100644 fr3on/slack-support-agent/group/slack.yaml delete mode 100644 fr3on/slack-support-agent/main.py delete mode 100644 fr3on/slack-support-agent/manifest.yaml delete mode 100644 fr3on/slack-support-agent/requirements.txt diff --git a/fr3on/slack-support-agent/.difyignore b/fr3on/slack-support-agent/.difyignore deleted file mode 100644 index bd138e822..000000000 --- a/fr3on/slack-support-agent/.difyignore +++ /dev/null @@ -1,7 +0,0 @@ -.env -.vscode/ -__pycache__/ -*.pyc -.DS_Store -.git/ -.gitignore diff --git a/fr3on/slack-support-agent/LICENSE b/fr3on/slack-support-agent/LICENSE deleted file mode 100644 index 5514244ee..000000000 --- a/fr3on/slack-support-agent/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2026 fr3on - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/fr3on/slack-support-agent/PRIVACY.md b/fr3on/slack-support-agent/PRIVACY.md deleted file mode 100644 index 86907436c..000000000 --- a/fr3on/slack-support-agent/PRIVACY.md +++ /dev/null @@ -1,31 +0,0 @@ -## Privacy Policy for Slack Support Agent - -This plugin acts as an extension to communicate with the Slack API and facilitate automated replies in Slack threads. - ---- - -**Data Collection** - -To function properly, this plugin requires you to provide Slack credentials (e.g., a Bot Token) when configuring the plugin in Dify. The following data is accessed or processed during normal operation: - -- **Slack credentials**: Necessary tokens to authenticate and access Slack’s API. -- **Slack messages and thread conversation history**: Depending on configuration, the plugin may read and process messages within Slack threads to provide replies. -- **Slack user information**: The plugin may retrieve basic user data (such as user IDs, display names) in order to mention or reference users within threads. - ---- - -**Data Usage** - -- **Credentials**: Slack tokens are used solely for making authorized requests to the Slack API. -- **Message Content**: The plugin processes thread conversation text so it can provide messages to your Dify runtime environment. -- **User Data**: Information about users in a Slack thread is used only to format mentions (`<@ID>`) or retrieve user context. -- **Data Retention**: To provide conversation context and avoid Slack's rate limits, the plugin caches recent thread/DM messages (channel, timestamps, sender ID, and text) in Dify's plugin storage for up to 24 hours, after which they expire automatically. It also stores the Dify `conversation_id` for each Slack thread or direct message so follow-up messages continue the same conversation; this value is retained until the thread/DM is superseded by a new one. This data stays within your own Dify plugin storage and is not shared with any third party beyond the Slack API calls and the linked Dify app you configure. - ---- - -**Third-Party Services / Self-Hosting** - -- The plugin communicates directly with Slack’s API, which is governed by Slack’s own privacy policy. For more information, refer to: - [https://slack.com/legal](https://slack.com/legal) -- Configuration of Slack (workspace settings, channels, permissions, etc.) is the sole responsibility of the user. -- This plugin is not affiliated with Slack, Inc. diff --git a/fr3on/slack-support-agent/README.md b/fr3on/slack-support-agent/README.md deleted file mode 100644 index fef050dd9..000000000 --- a/fr3on/slack-support-agent/README.md +++ /dev/null @@ -1,103 +0,0 @@ -## Slack Support Agent - -**Version:** 0.0.1 -**Type:** extension - -Connects Slack to a Dify chat app: reply within Slack threads and to direct messages sent straight to the bot, with conversation history, user info, file uploads, and `mrkdwn` formatting handled for you. - -### Contents - -- [Features](#features) -- [Settings](#settings) -- [Install](#install) -- [Setup](#setup) -- [Usage in your Dify app](#usage-in-your-dify-app) -- [Support](#support) - -### Features - -- **Threads** - reply within Slack threads, with an optional setting to also post the first reply to the channel -- **Direct messages** - reply to DMs sent straight to the bot, with the conversation continuing turn-to-turn just like Dify's own chat UI, not just @mentions in channels -- **File uploads** - Slack file attachments are downloaded and passed to the linked app automatically -- **`mrkdwn` formatting** - the app's Markdown answer is converted to Slack's `mrkdwn` and split across multiple messages if it exceeds Slack's block text limit -- **Context, your way** - the linked app receives thread/DM conversation history and a resolved user list; how much history it gets is configurable (see [Settings](#settings)) -- **Channel restriction** - optionally lock the bot to a single Slack channel - -> [!NOTE] -> The app also receives `channel_id` and `thread_ts` as inputs. These are for advanced use cases - e.g. a linked plugin posting follow-up messages to a specific channel or thread - and most setups won't need them. - -### Settings - -| Setting | Required | Description | -| --- | --- | --- | -| App | Yes | The Dify chat app that answers Slack messages. | -| Bot Token | Yes | Your Slack bot's `xoxb-...` token. | -| Allowed Channel | No | Restrict the bot to one channel, in `#channel` format. Leave blank to allow all channels. Doesn't apply to DMs. | -| Thread Context Sent to App | No | How much thread history to send with each reply: **Full thread** (default), **Root message only**, or **Message right before the mention**. | -| Broadcast First Reply | No | Also post the bot's first reply in a thread to the channel itself, not just the thread. | -| Allow Retry | No | Process Slack's automatic retries of a webhook delivery instead of ignoring them. Off by default to avoid duplicate replies. | -| Skip App Timeout Error Notifications | No | If the linked app times out, stay silent instead of posting an error message to Slack. | - -### Install - -To install this plugin, specify the following GitHub repository when selecting "Install Plugin": - -https://github.com/fr3on/slack-support-agent - -### Setup - -Follow the same setup procedure as the official SlackBot plugin, but with this plugin's scopes and events. - -**Bot token scopes:** - -```text -app_mentions:read, users:read, channels:history, groups:history, im:history, chat:write, groups:write, channels:read, -groups:read, im:read, files:read -``` - -**Subscribe to bot events:** - -| Event | Purpose | -| --- | --- | -| `app_mention` | Reply when someone @-mentions the bot in a channel | -| `message.im` | Reply to direct messages sent to the bot | -| `message.channels` | Cache messages in public channel threads | -| `message.groups` | Cache messages in private channel threads | - -The `message.channels`/`message.groups` events feed a caching layer that keeps thread context available without repeatedly hitting Slack's thread-history endpoint, which is rate-limited to about 1 request per minute. - -For details on setting up the official SlackBot plugin, see: - -https://github.com/langgenius/dify-official-plugins/blob/main/extensions/slack_bot/README.md - -### Usage in your Dify app - -In the start node of the chat flow app you link to this plugin, add these input fields to receive Slack context: - -| Input field | Field type | -| --- | --- | -| `thread_history` | Paragraph, max length e.g. 65535 | -| `thread_users` | Paragraph, max length e.g. 65535 | -| `files` | File List | -| `thread_ts` | Short Text, max length e.g. 48 | -| `channel_id` | Short Text, max length e.g. 48 | - -Example prompt (LLM node in the chat flow app): - -```text -You are an assistant on Slack who answers user questions. -Refer to the recent conversation history and provide an appropriate response. - -If you need to mention a specific user, refer to the user list and mention them in the format `<@ID>`. - -# Recent conversation history -Start.thread_history -# User list -Start.thread_users -``` - -### Support - -- Source repository: https://github.com/fr3on/slack-support-agent -- Issues / questions: https://github.com/fr3on/slack-support-agent/issues -- Contact: [@fr3on](https://github.com/fr3on) on GitHub diff --git a/fr3on/slack-support-agent/_assets/icon.svg b/fr3on/slack-support-agent/_assets/icon.svg deleted file mode 100644 index 40a916ccd..000000000 --- a/fr3on/slack-support-agent/_assets/icon.svg +++ /dev/null @@ -1,27 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/fr3on/slack-support-agent/endpoints/slack.py b/fr3on/slack-support-agent/endpoints/slack.py deleted file mode 100644 index c60d9ea01..000000000 --- a/fr3on/slack-support-agent/endpoints/slack.py +++ /dev/null @@ -1,698 +0,0 @@ -"""Slack endpoint for the Support Agent plugin. - -Receives Slack Events API webhooks (channel @mentions, direct messages, and -passive channel/group messages used to build thread context), forwards the -conversation to a linked Dify chat app, and posts the answer back to Slack. -""" - -import json -import logging -import re -import time -import traceback -from dataclasses import dataclass -from typing import Any, Dict, List, Mapping, Optional - -import requests -from dify_plugin import Endpoint -from slack_sdk import WebClient -from slack_sdk.errors import SlackApiError -from werkzeug import Request, Response - -logger = logging.getLogger(__name__) - -MENTION_PATTERN = re.compile(r"<@([A-Za-z0-9]+)>") -LOOSE_MENTION_PATTERN = re.compile(r"<@([^>]+)>") -SLACK_BLOCK_TEXT_LIMIT = 3000 # https://api.slack.com/reference/block-kit/composition-objects#text__fields -DM_CONVERSATION_ANCHOR = "dm-main" - - -class SlackMarkdownConverter: - """Converts Markdown text into Slack's mrkdwn format. - - ref: https://github.com/fla9ua/markdown_to_mrkdwn - """ - - def __init__(self, encoding: str = "utf-8"): - self.encoding = encoding - self.in_code_block = False - self.table_replacements: Dict[str, str] = {} - self.patterns: List[tuple] = [ - (re.compile(r"^(\s*)- \[([ ])\] (.+)", re.MULTILINE), r"\1• ☐ \3"), # Unchecked task list - (re.compile(r"^(\s*)- \[([xX])\] (.+)", re.MULTILINE), r"\1• ☑ \3"), # Checked task list - (re.compile(r"^(\s*)- (.+)", re.MULTILINE), r"\1• \2"), # Unordered list - (re.compile(r"^(\s*)(\d+)\. (.+)", re.MULTILINE), r"\1\2. \3"), # Ordered list - (re.compile(r"!\[.*?\]\((.+?)\)", re.MULTILINE), r"<\1>"), # Images to URL - (re.compile(r"(?"), # Links - (re.compile(r"`(.+?)`", re.MULTILINE), r"`\1`"), # Inline code - (re.compile(r"^> (.+)", re.MULTILINE), r"> \1"), # Blockquote - (re.compile(r"^(---|\*\*\*|___)$", re.MULTILINE), r"──────────"), # Horizontal line - (re.compile(r"~~(.+?)~~", re.MULTILINE), r"~\1~"), # Strikethrough - ] - self.triple_start = "%%BOLDITALIC_START%%" - self.triple_end = "%%BOLDITALIC_END%%" - - def convert(self, markdown: str) -> str: - if not markdown: - return "" - - try: - markdown = markdown.strip() - self.table_replacements = {} - markdown = self._convert_tables(markdown) - - converted_lines = [self._convert_line(line) for line in markdown.split("\n")] - result = "\n".join(converted_lines) - - for placeholder, table in self.table_replacements.items(): - result = result.replace(placeholder, table) - - return result.encode(self.encoding).decode(self.encoding) - except Exception: - return markdown - - def _convert_tables(self, markdown: str) -> str: - table_pattern = re.compile( - r"^\|(.+)\|\s*$\n^\|[-:| ]+\|\s*$(\n^\|.+\|\s*$)*", re.MULTILINE - ) - - def convert_table(match: re.Match) -> str: - original_table = match.group(0) - table_lines = original_table.strip().split("\n") - header_line = table_lines[0] - data_lines = table_lines[2:] if len(table_lines) > 2 else [] - - headers = [cell.strip() for cell in header_line.strip("|").split("|")] - rows = [ - [cell.strip() for cell in line.strip("|").split("|")] - for line in data_lines - ] - - result = [" | ".join(f"*{header}*" for header in headers)] - result.extend(" | ".join(row) for row in rows) - - placeholder = f"%%TABLE_PLACEHOLDER_{hash(original_table)}%%" - self.table_replacements[placeholder] = "\n".join(result) - return placeholder - - return table_pattern.sub(convert_table, markdown) - - def _convert_line(self, line: str) -> str: - if line.startswith("%%TABLE_PLACEHOLDER_") and line.endswith("%%"): - return line - - code_block_match = re.match(r"^```(\w*)$", line) - if code_block_match: - language = code_block_match.group(1) - self.in_code_block = not self.in_code_block - return f"```{language}" if self.in_code_block and language else "```" - - if self.in_code_block: - return line - - line = re.sub( - r"(? Dict[str, Any]: - # `ts` is only used internally to filter by context_scope - it's - # never sent to the linked app. - return { - "role": self.role, - "participant_id": self.participant_id, - "content": self.content, - "participant_name": self.participant_name, - } - - -@dataclass -class RoutingContext: - """Where an incoming Slack event routes to, and how the bot should reply. - - For a channel @mention, or a DM reply the user explicitly threaded, - `raw_thread_ts` is a real Slack thread timestamp and every property below - is simply that value (or the event's own ts for a brand-new thread). - - For a plain DM message - the common case, since Slack doesn't set - thread_ts unless the user threads a reply - there's no natural thread to - anchor to. Anchoring each message to its own ts would start a brand-new - Dify conversation on every single message, so instead the whole DM - channel shares one fixed conversation anchor, and replies post as normal - top-level DM messages rather than a threaded reply under each message. - """ - - channel: str - event_ts: Optional[str] - is_dm: bool - raw_thread_ts: Optional[str] - - @property - def _is_freeform_dm(self) -> bool: - return self.is_dm and not self.raw_thread_ts - - @property - def conversation_ts(self) -> str: - """Anchors our cache/conversation storage keys.""" - if self._is_freeform_dm: - return DM_CONVERSATION_ANCHOR - return self.raw_thread_ts or self.event_ts - - @property - def reply_thread_ts(self) -> Optional[str]: - """Value passed to Slack's chat_postMessage/conversations_replies.""" - if self._is_freeform_dm: - return None - return self.raw_thread_ts or self.event_ts - - @property - def slack_thread_ts(self) -> Optional[str]: - """The real Slack timestamp handed to the linked Dify app as input, - for use by downstream plugins (e.g. Slack Post) - never our internal - DM conversation anchor.""" - return self.raw_thread_ts or self.event_ts - - -class SlackEndpoint(Endpoint): - CACHE_PREFIX = "thread-cache" - CONVERSATION_PREFIX = "slack" - CACHE_DURATION = 60 * 60 * 24 # 1 day - - # ---------------------------------------------------------------- cache - - def _cache_key(self, channel: str, conversation_ts: str) -> str: - return f"{self.CACHE_PREFIX}-{channel}-{conversation_ts}" - - def _conversation_key(self, channel: str, conversation_ts: str) -> str: - return f"{self.CONVERSATION_PREFIX}-{channel}-{conversation_ts}" - - def _load_cached_history(self, channel: str, conversation_ts: str) -> List[Dict[str, Any]]: - key = self._cache_key(channel, conversation_ts) - try: - raw = self.session.storage.get(key) - except Exception: - return [] - if not raw: - return [] - - try: - all_messages = json.loads(raw.decode("utf-8")).get("messages", []) - except Exception: - return [] - - now = time.time() - active = [m for m in all_messages if now - m.get("saved_at", now) < self.CACHE_DURATION] - if len(active) != len(all_messages): - self._write_cache(channel, conversation_ts, active) - return active - - def _write_cache(self, channel: str, conversation_ts: str, messages: List[Dict[str, Any]]) -> None: - try: - self.session.storage.set( - self._cache_key(channel, conversation_ts), - json.dumps({"messages": messages}).encode("utf-8"), - ) - except Exception: - pass - - def _append_thread_message(self, channel: str, conversation_ts: str, message: Mapping) -> None: - messages = self._load_cached_history(channel, conversation_ts) - msg = dict(message) - msg["saved_at"] = time.time() - messages.append(msg) - self._write_cache(channel, conversation_ts, messages) - - def _get_conversation_id(self, channel: str, conversation_ts: str) -> Optional[str]: - try: - raw = self.session.storage.get(self._conversation_key(channel, conversation_ts)) - except Exception: - return None - return raw.decode("utf-8") if raw else None - - def _set_conversation_id(self, channel: str, conversation_ts: str, conversation_id: str) -> None: - try: - self.session.storage.set( - self._conversation_key(channel, conversation_ts), - conversation_id.encode("utf-8"), - ) - except Exception: - pass - - # -------------------------------------------------------------- routing - - def _invoke(self, r: Request, values: Mapping, settings: Mapping) -> Response: - if self._is_ignorable_retry(r, settings): - return Response(status=200, response="ok") - - data = r.get_json(silent=True) or {} - - if data.get("type") == "url_verification": - return Response( - response=json.dumps({"challenge": data.get("challenge")}), - status=200, - content_type="application/json", - ) - - if data.get("type") != "event_callback": - return Response(status=200, response="ok") - - event = data.get("event") or {} - is_dm = self._is_dm_message(event) - - if event.get("type") == "app_mention" or is_dm: - return self._handle_reply(event, settings, is_dm) - if event.get("type") == "message": - return self._handle_passive_message(event) - return Response(status=200, response="ok") - - @staticmethod - def _is_ignorable_retry(r: Request, settings: Mapping) -> bool: - if settings.get("allow_retry"): - return False - retry_num = r.headers.get("X-Slack-Retry-Num") - return bool( - r.headers.get("X-Slack-Retry-Reason") == "http_timeout" - or (retry_num is not None and int(retry_num) > 0) - ) - - @staticmethod - def _is_dm_message(event: Mapping) -> bool: - """Slack sends type="message" with channel_type="im" for a DM (no - app_mention fires in a DM, since there's no one else to @-mention). - Guard against bot_id/subtype so we never reply to our own posts or to - edits/deletes - either would otherwise create a reply loop.""" - return bool( - event.get("type") == "message" - and event.get("channel_type") == "im" - and not event.get("bot_id") - and not event.get("subtype") - and event.get("text") - ) - - @staticmethod - def _event_to_cache_entry(event: Mapping) -> Dict[str, Any]: - return { - "ts": event.get("ts"), - "text": event.get("text", ""), - "user": event.get("user"), - "bot_id": event.get("bot_id"), - } - - # ------------------------------------------------- passive caching path - - def _handle_passive_message(self, event: Mapping) -> Response: - """Messages that aren't a mention or a DM (e.g. other people replying - in a channel thread the bot is already tracking) are cached silently, - without invoking the linked app, so full thread context is available - next time the bot is mentioned in that thread.""" - channel = event.get("channel", "") - thread_ts = event.get("thread_ts") or event.get("ts") - if self._is_thread_recognized(channel, thread_ts): - self._append_thread_message(channel, thread_ts, self._event_to_cache_entry(event)) - return Response(status=200, response="ok") - - def _is_thread_recognized(self, channel: str, thread_ts: str) -> bool: - for key in ( - self._conversation_key(channel, thread_ts), - self._cache_key(channel, thread_ts), - ): - try: - if self.session.storage.get(key): - return True - except Exception: - pass - return False - - # ----------------------------------------------------- mention -> reply - - def _handle_reply(self, event: Mapping, settings: Mapping, is_dm: bool) -> Response: - ctx = RoutingContext( - channel=event.get("channel", ""), - event_ts=event.get("ts"), - is_dm=is_dm, - raw_thread_ts=event.get("thread_ts"), - ) - client = WebClient(token=settings.get("bot_token")) - message_text = self._extract_message_text(event) - - self._append_thread_message(ctx.channel, ctx.conversation_ts, self._event_to_cache_entry(event)) - - blocked = self._enforce_allowed_channel(client, ctx, settings, is_dm) - if blocked is not None: - return blocked - - try: - return self._respond(client, ctx, settings, event, message_text) - except Exception as e: - return self._handle_invoke_error(client, ctx, settings, e) - - @staticmethod - def _extract_message_text(event: Mapping) -> str: - text = event.get("text", "") - if event.get("type") == "app_mention": - # Remove the leading bot mention, e.g. "<@U123> what's up" -> "what's up". - # DMs carry no leading mention, so nothing to strip there. - return re.sub(r"^<@[^>]+>\s*", "", text) - return text - - def _enforce_allowed_channel( - self, client: WebClient, ctx: RoutingContext, settings: Mapping, is_dm: bool - ) -> Optional[Response]: - """Returns a short-circuit Response if this channel isn't allowed (or - the check itself failed), else None to continue. DMs have no channel - name to compare against - the restriction is a channel-scoping - feature, so it's skipped for DMs entirely.""" - allowed_channel = settings.get("allowed_channel", "").strip() - if not allowed_channel or is_dm: - return None - - try: - channel_info = client.conversations_info(channel=ctx.channel) - actual_channel = f"#{channel_info['channel']['name']}" - except SlackApiError as e: - logger.warning("Error getting channel info: %s", e) - self._safe_post(client, ctx, f"Failed to retrieve channel info. SlackApiError: {e}") - return Response(status=200, response="ok", content_type="text/plain") - except Exception as e: - logger.warning("Unexpected error getting channel info: %s", e) - self._safe_post( - client, ctx, f"An unexpected error occurred while retrieving channel info. Error: {e}" - ) - return Response(status=200, response="ok", content_type="text/plain") - - if actual_channel == allowed_channel: - return None - - self._safe_post(client, ctx, f"Current channel: {actual_channel} is not allowed.") - return Response(status=200, response="ok", content_type="text/plain") - - @staticmethod - def _safe_post(client: WebClient, ctx: RoutingContext, text: str) -> None: - try: - client.chat_postMessage(channel=ctx.channel, thread_ts=ctx.reply_thread_ts, text=text) - except SlackApiError: - pass - - # ------------------------------------------------------- app invocation - - def _respond( - self, client: WebClient, ctx: RoutingContext, settings: Mapping, event: Mapping, message_text: str - ) -> Response: - conversation_id = self._get_conversation_id(ctx.channel, ctx.conversation_ts) - - raw_messages = self._fetch_thread_messages(client, ctx) - user_display_names = self._resolve_display_names(client, raw_messages) - thread_history, is_first_message = self._build_thread_history(raw_messages, user_display_names) - thread_history = self._apply_context_scope( - thread_history, settings.get("context_scope", "full_thread"), ctx.event_ts - ) - - uploaded_files = self._upload_slack_files( - client, ctx, settings.get("bot_token"), event.get("files", []) - ) - - invoke_params: Dict[str, Any] = { - "app_id": settings["app"]["app_id"], - "query": self._substitute_mentions(message_text, user_display_names), - "inputs": self._build_invoke_inputs(ctx, thread_history, user_display_names, uploaded_files), - "response_mode": "blocking", - } - if conversation_id is not None: - invoke_params["conversation_id"] = conversation_id - - response = self.session.app.chat.invoke(**invoke_params) - answer = response.get("answer") - - new_conversation_id = response.get("conversation_id") - if new_conversation_id: - self._set_conversation_id(ctx.channel, ctx.conversation_ts, new_conversation_id) - - try: - return self._post_answer(client, ctx, answer, settings, is_first_message) - except SlackApiError as e: - return Response( - status=200, response=f"Error sending message to Slack: {e}", content_type="text/plain" - ) - - def _fetch_thread_messages(self, client: WebClient, ctx: RoutingContext) -> List[Dict[str, Any]]: - """Raw Slack messages for this thread/DM, from cache if present, else - fetched from Slack. Only falls back to Slack's API when we have a - real thread ts - the synthetic DM anchor isn't one, and the cache is - never empty here anyway since the triggering message was already - appended to it in `_handle_reply`.""" - messages = self._load_cached_history(ctx.channel, ctx.conversation_ts) - if messages or not ctx.reply_thread_ts: - return messages - - messages = self._fetch_replies_with_retry(client, ctx) - for m in messages: - self._append_thread_message(ctx.channel, ctx.conversation_ts, m) - return messages - - def _fetch_replies_with_retry(self, client: WebClient, ctx: RoutingContext) -> List[Dict[str, Any]]: - try: - return client.conversations_replies(channel=ctx.channel, ts=ctx.reply_thread_ts).get("messages", []) - except SlackApiError as e: - if e.response.get("error") != "ratelimited": - logger.warning("Error getting thread history: %s", e) - return [] - - retry_after = int(e.response.get("headers", {}).get("Retry-After", 60)) - self._safe_post( - client, ctx, f"Rate limit reached when retrieving thread. Retrying in {retry_after} seconds..." - ) - time.sleep(retry_after) - try: - return client.conversations_replies(channel=ctx.channel, ts=ctx.reply_thread_ts).get("messages", []) - except SlackApiError as retry_error: - logger.warning("Error getting thread history after retry: %s", retry_error) - return [] - - def _resolve_display_names(self, client: WebClient, messages: List[Dict[str, Any]]) -> Dict[str, str]: - user_ids: List[str] = [] - for msg in messages: - user_id = msg.get("user", "unknown") - if user_id != "unknown" and user_id not in user_ids: - user_ids.append(user_id) - for mentioned_id in LOOSE_MENTION_PATTERN.findall(msg.get("text") or ""): - if mentioned_id not in user_ids: - user_ids.append(mentioned_id) - - display_names: Dict[str, str] = {} - try: - for user_id in user_ids: - info = client.users_info(user=user_id).get("user", {}) - name, real_name = info.get("name", ""), info.get("real_name", "") - display_names[user_id] = f"{real_name} ({name})" if name else real_name - except SlackApiError as e: - logger.warning("Error getting user info: %s", e) - return display_names - - def _build_thread_history( - self, messages: List[Dict[str, Any]], user_display_names: Dict[str, str] - ) -> tuple: - thread_history = [ - ThreadMessage( - role="assistant" if msg.get("bot_id") else "user", - participant_id=(participant_id := msg.get("user", "unknown")), - content=self._substitute_mentions(msg.get("text", ""), user_display_names), - ts=msg.get("ts"), - participant_name=user_display_names.get(participant_id, "unknown"), - ) - for msg in messages - ] - # Decided from the untrimmed history, before _apply_context_scope - # shrinks it down to 0-1 items regardless of the thread's real length. - is_first_message = len(thread_history) == 1 - return thread_history, is_first_message - - @staticmethod - def _apply_context_scope( - thread_history: List[ThreadMessage], scope: str, trigger_ts: Optional[str] - ) -> List[ThreadMessage]: - """Trims thread_history down to the configured scope. The triggering - message itself is excluded from the trimmed scopes since it's already - sent separately as the query.""" - if scope == "full_thread": - return thread_history - - without_trigger = [m for m in thread_history if m.ts != trigger_ts] - if scope == "parent_message": - return without_trigger[:1] - if scope == "last_message": - return without_trigger[-1:] - return thread_history - - @staticmethod - def _substitute_mentions(text: str, user_display_names: Mapping[str, str]) -> str: - def replace(match: re.Match) -> str: - user_id = match.group(1) - return f"@{user_display_names[user_id]}" if user_id in user_display_names else match.group(0) - - return MENTION_PATTERN.sub(replace, text) - - def _upload_slack_files( - self, client: WebClient, ctx: RoutingContext, token: str, slack_files: List[Dict[str, Any]] - ) -> List[Any]: - uploaded = [] - for f in slack_files: - file_name = f.get("name") - file_url = f.get("url_private_download") - mimetype = f.get("mimetype", "application/octet-stream") - if not file_url or not file_name: - continue - - resp = requests.get(file_url, headers={"Authorization": f"Bearer {token}"}) - if resp.status_code != 200: - logger.warning( - "Failed to download file from Slack: %s, status code=%s", file_name, resp.status_code - ) - continue - - try: - storage_file = self.session.file.upload(filename=file_name, content=resp.content, mimetype=mimetype) - if storage_file: - uploaded.append(storage_file) - except Exception as e: - self._safe_post( - client, - ctx, - f"Error uploading file: {e}\n\n" - "This may be caused by an unconfigured `FILES_URL` in your `dify/docker/.env` .\n" - "Please set `FILES_URL` properly and restart( `docker compose down && docker compose up -d` ) " - "your Dify environment, then try again.", - ) - logger.warning("Error uploading file via session.file.upload: %s", e) - return uploaded - - @staticmethod - def _build_invoke_inputs( - ctx: RoutingContext, - thread_history: List[ThreadMessage], - user_display_names: Dict[str, str], - uploaded_files: List[Any], - ) -> Dict[str, Any]: - inputs: Dict[str, Any] = { - "thread_history": json.dumps([m.to_dict() for m in thread_history], indent=4, ensure_ascii=False), - "thread_users": json.dumps(user_display_names, indent=4, ensure_ascii=False), - "thread_ts": ctx.slack_thread_ts, - "channel_id": ctx.channel, - } - if uploaded_files: - inputs["files"] = [ - {"type": f.type, "transfer_method": "remote_url", "url": f.preview_url} - for f in uploaded_files - ] - return inputs - - def _handle_invoke_error( - self, client: WebClient, ctx: RoutingContext, settings: Mapping, error: Exception - ) -> Response: - err_msg = str(error) - err_trace = traceback.format_exc() - - skip_timeout_error = settings.get("skip_timeout_error", False) - if skip_timeout_error and "invocation exited without response" in err_msg.lower(): - return Response(status=200, response="ok", content_type="text/plain") - - self._safe_post( - client, ctx, f"Sorry, I'm having trouble processing your request. Please try again later. Error: {err_msg}" - ) - return Response( - status=200, response=f"An error occurred: {err_msg}\n{err_trace}", content_type="text/plain" - ) - - # ------------------------------------------------------------- replying - - def _post_answer( - self, client: WebClient, ctx: RoutingContext, answer: str, settings: Mapping, is_first_message: bool - ) -> Response: - converted_answer = SlackMarkdownConverter().convert(answer) - chunks = self._split_into_chunks(converted_answer, SLACK_BLOCK_TEXT_LIMIT) - reply_broadcast = settings.get("first_reply_broadcast", False) and is_first_message - - for i, chunk in enumerate(chunks): - resp = client.chat_postMessage( - channel=ctx.channel, - text=chunk, # fallback text - thread_ts=ctx.reply_thread_ts, - blocks=[{"type": "section", "text": {"type": "mrkdwn", "text": chunk}}], - # Only broadcast the first chunk - broadcasting every chunk - # would flood the channel outside the thread. - reply_broadcast=reply_broadcast if i == 0 else False, - ) - self._append_thread_message( - ctx.channel, - ctx.conversation_ts, - { - "ts": resp.get("ts"), - "text": chunk, - "user": resp.get("message", {}).get("user"), - "bot_id": resp.get("message", {}).get("bot_id"), - }, - ) - - return Response(status=200, response="ok", content_type="text/plain") - - @staticmethod - def _split_into_chunks(text: str, max_len: int) -> List[str]: - """Greedily packs lines into chunks up to `max_len` characters, - splitting any single line that's longer than max_len on its own.""" - if len(text) <= max_len: - return [text] - - def pieces(line: str): - if len(line) <= max_len: - yield line - else: - for i in range(0, len(line), max_len): - yield line[i : i + max_len] - - chunks: List[str] = [] - current = "" - for line in text.split("\n"): - for piece in pieces(line): - added_len = len(piece) + (1 if current else 0) - if len(current) + added_len <= max_len: - current = f"{current}\n{piece}" if current else piece - else: - if current: - chunks.append(current) - current = piece - if current: - chunks.append(current) - return chunks diff --git a/fr3on/slack-support-agent/endpoints/slack.yaml b/fr3on/slack-support-agent/endpoints/slack.yaml deleted file mode 100644 index f1ab36180..000000000 --- a/fr3on/slack-support-agent/endpoints/slack.yaml +++ /dev/null @@ -1,5 +0,0 @@ -path: "/" -method: "POST" -extra: - python: - source: "endpoints/slack.py" diff --git a/fr3on/slack-support-agent/group/slack.yaml b/fr3on/slack-support-agent/group/slack.yaml deleted file mode 100644 index 16015b480..000000000 --- a/fr3on/slack-support-agent/group/slack.yaml +++ /dev/null @@ -1,98 +0,0 @@ -settings: - - name: bot_token - type: secret-input - required: true - label: - en_US: Bot Token - zh_Hans: Bot Token - pt_BR: Token do Bot - ja_JP: Bot Token - placeholder: - en_US: Please input your Bot Token - zh_Hans: 请输入你的 Bot Token - pt_BR: Por favor, insira seu Token do Bot - ja_JP: ボットトークンを入力してください - - name: first_reply_broadcast - type: boolean - required: false - label: - en_US: Broadcast First Reply - zh_Hans: 广播首次回复 - pt_BR: Transmitir Primeira Resposta - ja_JP: 初回のレスポンスをチャンネルにも送信する - default: false - - name: allow_retry - type: boolean - required: false - label: - en_US: Allow Retry - zh_Hans: 允许重试 - pt_BR: Permitir Retentativas - ja_JP: 再試行を許可 - default: false - - name: skip_timeout_error - type: boolean - required: false - label: - en_US: Skip App Timeout Error Notifications - zh_Hans: 跳过应用程序超时错误通知 - pt_BR: Pular Notificações de Erro de Timeout do Aplicativo - ja_JP: アプリのタイムアウトエラー通知をスキップ - default: false - - name: context_scope - type: select - required: false - label: - en_US: Thread Context Sent to App - zh_Hans: 发送给应用的线程上下文 - pt_BR: Contexto da Thread Enviado ao App - ja_JP: アプリに送信するスレッドの文脈 - options: - - value: full_thread - label: - en_US: Full thread - zh_Hans: 完整线程 - pt_BR: Thread completa - ja_JP: スレッド全体 - - value: parent_message - label: - en_US: Root message only - zh_Hans: 仅根消息 - pt_BR: Apenas mensagem raiz - ja_JP: 起点メッセージのみ - - value: last_message - label: - en_US: Message right before the mention - zh_Hans: 提及前的最后一条消息 - pt_BR: Mensagem anterior à menção - ja_JP: メンション直前のメッセージのみ - default: full_thread - - name: allowed_channel - type: text-input - required: false - label: - en_US: "Allowed Channel (in #channel format)" - zh_Hans: "允许使用的频道(#channel 格式)" - pt_BR: "Canal Permitido (no formato #channel)" - ja_JP: "利用を許可するチャンネル(#channel 形式)" - placeholder: - en_US: "Leave blank to allow all" - zh_Hans: "留空则允许所有频道" - pt_BR: "Deixe em branco para permitir todos" - ja_JP: "未入力の場合は全てのチャンネルで利用可能" - default: "" - - name: app - type: app-selector - required: true - label: - en_US: App - zh_Hans: 应用 - pt_BR: App - ja_JP: アプリ - placeholder: - en_US: the app you want to use to answer Slack messages - zh_Hans: 你想要用来回答 Slack 消息的应用 - pt_BR: o app que você deseja usar para responder mensagens do Slack - ja_JP: あなたが Slack メッセージに回答するために使用するアプリ -endpoints: - - endpoints/slack.yaml diff --git a/fr3on/slack-support-agent/main.py b/fr3on/slack-support-agent/main.py deleted file mode 100644 index f2bfcdfc0..000000000 --- a/fr3on/slack-support-agent/main.py +++ /dev/null @@ -1,6 +0,0 @@ -from dify_plugin import Plugin, DifyPluginEnv - -plugin = Plugin(DifyPluginEnv()) - -if __name__ == '__main__': - plugin.run() diff --git a/fr3on/slack-support-agent/manifest.yaml b/fr3on/slack-support-agent/manifest.yaml deleted file mode 100644 index 3e7632bda..000000000 --- a/fr3on/slack-support-agent/manifest.yaml +++ /dev/null @@ -1,51 +0,0 @@ -version: 0.0.1 -type: plugin -author: fr3on -name: slack-support-agent -label: - en_US: Slack Support Agent - ja_JP: Slack Support Agent - zh_Hans: Slack Support Agent - pt_BR: Slack Support Agent -description: - en_US: "Connects Slack to a Dify agent for thread replies (optionally posting the first reply to the channel) and direct messages, with mrkdwn formatting, thread/DM conversation history and user list, configurable context scope, and optional usage channel restriction." - ja_JP: "スレッド返信(初回返信をチャンネルにも投稿可)とダイレクトメッセージに対応したSlack向けDifyエージェント連携プラグイン。mrkdwn対応、スレッド/DM履歴・ユーザーリスト参照、文脈範囲の設定、任意の利用チャンネル制限に対応。" - zh_Hans: "将 Slack 连接到 Dify 智能体的插件,支持线程回复(可选将首次回复发送至频道)和私信,支持 mrkdwn 格式、线程/私信历史和用户列表、可配置的上下文范围,并可选限制使用频道。" - pt_BR: "Conecta o Slack a um agente Dify para respostas em thread (opcionalmente postando a primeira resposta no canal) e mensagens diretas, com formatação mrkdwn, histórico de conversa em thread/DM e lista de usuários, escopo de contexto configurável, e restrição opcional de canais de uso." -icon: icon.svg -tags: ['productivity','social'] -resource: - memory: 536870912 - permission: - tool: - enabled: true - model: - enabled: true - llm: true - text_embedding: false - rerank: false - tts: false - speech2text: false - moderation: false - endpoint: - enabled: true - app: - enabled: true - storage: - enabled: true - size: 1048576 -plugins: - endpoints: - - group/slack.yaml -meta: - version: 0.0.1 - arch: - - amd64 - - arm64 - runner: - language: python - version: "3.12" - entrypoint: main -created_at: 2026-09-09T00:00:00Z -privacy: PRIVACY.md -verified: false diff --git a/fr3on/slack-support-agent/requirements.txt b/fr3on/slack-support-agent/requirements.txt deleted file mode 100644 index 98a46f9d8..000000000 --- a/fr3on/slack-support-agent/requirements.txt +++ /dev/null @@ -1,4 +0,0 @@ -dify_plugin==0.2.4 -Werkzeug==3.0.6 -slack_sdk==3.35.0 -requests==2.32.3 From 320910198288643344c771b5263d258a5efa756d Mon Sep 17 00:00:00 2001 From: fr3on Date: Sat, 12 Sep 2026 15:11:00 +0300 Subject: [PATCH 3/4] Fix pre-check failures: add manifest repo/contact fields, bump dify_plugin dependency - manifest.yaml: add required repo and contact fields - requirements.txt: bump dify_plugin from 0.2.4 to 0.9.0 (marketplace requires >= 0.9.0) --- .../slack-support-agent-0.0.1.difypkg | Bin 16435 -> 16474 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/fr3on/slack-support-agent/slack-support-agent-0.0.1.difypkg b/fr3on/slack-support-agent/slack-support-agent-0.0.1.difypkg index 0fcd4f76f7acfb31e25a6ee46e9347583e8de84e..bfc2c037fa0e544a9930660fbe43655f3c0c9325 100644 GIT binary patch delta 1339 zcmV-B1;qNZfC1Wo0kB#)f7C`0fA^;t-Ah2aTrL3u_^A{g+Db?W+DdIzMH_o_x5eJI zcGrR9>1+qMK;RNcL6DX<bn(l7Rx3(5{nzgw0d%W-{eO2Kd6$L7KtfDm)f8A2i3l+RjVc5R$Yp1al z*H?C$(<*4FsH(yz8Y)~-QB6gQ8Z+EbQB#F)Rq!HS`7hr3O$GN=@JGUZJUgd?InBKa z8<|_Ulw_&(s*l^V?^SePMbA~Znjo*KYM8Q@K~;mj#Sfi18~^jP{pyX1HdOR10bJMr z(cg(%Ev|>1f2&KX8no|S?@rY^D^u~pYZWYa8?WN!hpHO1Ha6N*brmefwdbAr#z$Sp zwe{YE+1*B}VQ%ky=Hk_B*-Uk}9<^^jjccpvV6`8-??#)g&AXk4Q|osL(p^`6~pzg$euf8+XGuNri(ul4?#>jigrn$z)} zna)y>e5nklKUM8Nn{l(Qsu}Q5>K5O=7S~_vsQE^__HSIf-(75|YM|3yz17)V?mV1o zy}i+Tds8zr6U7T}cA7KsyY;l5-hdZIN8nWIq@o7n6OCn%d{|J0Hl@m@G z@DN9Oe*m?5kO1uVC3c8;Rx+$^;+)v2ttIJk;$pHV3@8H&QREy33@)a6xT#1)CXvvx za5GV%?WOIeeltk~qKJqTkQH(UvGgw0{{=Bwe=<1smLC zpo?9`D<&K}GJ4{8@#OG`{*0KrM2Kup0!YT3^hs|`y=H6^B=>Q0<}w?9#th&%?!I?T zf7}>$$FXe_S~6h*Iihdlm?L^||Cy9x|AFu@w#p+KXTL$0!joOO_Z(B(V}#0sYeb%R zuu<<^RKlO@P>?IwgyG`R6UUB^8tI)V(mN(iEz;;gC~@X{>DMe&kn0#OmPi{vx?KSf zx61t}p1Dsc=BbB+Xvx<#V1ieXWk!?34~V%Vh2fE0Mup^+lnihYp@zjV zlGq+Y(u9%X$mn44T$$(4R2QH;Pa3=8q_#RFD_rhVuYt^ZLBPtFvyK?GZ7ysF4VPSIO zO5Ma>3Hh^(Qfx|*#YhGS&XIBrl9Hj!ILIo;$O4XCn_vM)SOS73lF$msDuy!Q zLMC1{4Wp0DDxwtIe@6jP3oOF1DZ#-)pb8cRxJqOd9Dc>RN_8FwNsU#=@{OH44<@6U zcck+{l?cgrEy3~x=3o;c3)-hlDjr9dZLB{e{y~B;nTrdF>#$nzblPD_z_E~=nA%p% z@3Ed&!3Pz-R$-{Z*?woS+gaXiH=@SR(bALjQw8@_=&SIle+oBLct?e=l>bTvL451S zUaJ!|*LK^p%5SN#u7YP8Dp*rtLxsy4GuTpLTLtfx|0-JhE9(5D{D;c_CE-4rUr_#n z=3WJ@%q>_+vNQ(ur}6wp6+TqqOBJjq$Q!C2r0iu-b$@^HW9QCCe>{)hyi?(p3ST6E zoBA34p13ule`e6TzM|@W{NQGPrqNrQiI(0ff3@Fw6Rkd0b-%l{70)!4zZx}O_7+>8 zbsaS}2ao3W8mR~Q{qvcR)^B7p)$crs?>>(j>*-*{k3ROp?e6yd-s73Ld9S;*x!azL z7uVuBzxT1zYu%2PooM;Pa2TyoA%bYM#0IXFtjM6e=pTxJb zJdN!F;7nF&#$hu_1fqzD6p$73Ib!Nvs-HzMe_hE*R1pSzJK}O?d8Q=OMAk+MBZ8R( z*(2ZOI2O#~Cf1wF!4Bg!15O;9Jaw{kdVE4tkGVsH$aWimWXw+A^rq9R!7@N{4<~01 zv+x(p0JiNM_}0x%VdpZoEJ7;=l#wllHjX)>R}P*@DGnY87h|(Jp>YlxbSXU9Z~MBxrPlGFC9O1;^bs5y~jj)FQlnO8a)aX&OCQ4xz36; zscfDHF{>O7h0&Igk*JwF69(lg Date: Sun, 13 Sep 2026 10:28:08 +0300 Subject: [PATCH 4/4] Rebuild package: fix Werkzeug/requests version conflict with dify_plugin 0.9.0 --- .../slack-support-agent-0.0.1.difypkg | Bin 16474 -> 16443 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/fr3on/slack-support-agent/slack-support-agent-0.0.1.difypkg b/fr3on/slack-support-agent/slack-support-agent-0.0.1.difypkg index bfc2c037fa0e544a9930660fbe43655f3c0c9325..c81d84c3d95489f6fde55f20b560e9d07d617f50 100644 GIT binary patch delta 73 zcmccBz_`1CaYKo%s@6GApL2csekZqWHN9qRcGdU+6AJ^w|NjBr>>Ory?DMr57#LJH W@34(y;)HW2%R88|&9Mc_FaQ7<%NO|o delta 103 zcmdnpz<8^HaYKo%3zxs{sdZkuzT3B&UNgR6{8;Opr_Z@QeZP}zoD#ENNfwAM6`Qt9 zY`VzpTT(A2rC&-GGcqwS{Qn=|&CX%xo8{ugz`)?Pd8Ta~6DOQI$-$Iuw=GbD0RTXJ BBUJzZ