slackapi / deno-slack-sdk

SDK for building Run on Slack apps using Deno
https://api.slack.com/automation
MIT License
158 stars 27 forks source link

[QUERY] Calling a custom function from within another custom function #238

Closed markfoden closed 10 months ago

markfoden commented 10 months ago

I'm looking for help on the SDK. Not sure this is the right place as the other issues posted here look pretty technical. But I was encouraged to post here by a member of the Slack support team so here it is...

I am trying to call a custom function from within another custom function (as in the code example below) but I get "TypeError: InnerFunction is not a function".

I guess the InnerFunction definition is out of scope but I don't know enough about the SDK (and probably Typescript) to fix the issue

I can't find any reference to this in the documentation or examples.

Can someone help? Or explain how I otherwise might get help?

Thanks very much

Mark

import { DefineFunction, Schema, SlackFunction } from "deno-slack-sdk/mod.ts";
import { InnerFunction } from "./inner_function.ts";

export const OuterFunction = DefineFunction({
  // definition here
});

export default SlackFunction (
  OuterFunction, 
  ({inputs}) => {
    const a = InnerFunction ({
      test_input: inputs.test
    })
    return a
  }
);
filmaj commented 10 months ago

It looks like you have two Slack Custom capital-F Functions: InnerFunction and OuterFunction.

Is InnerFunction used as a step in a Workflow elsewhere? If not, it shouldn't be necessary to wrap it in DefineFunction - that effectively turns a bit of logic/code into a Slack Custom Function, and enables it to be used a step within a Workflow. If that's not required, you can use a plain old lower-case-F JavaScript/TypeScript function within your OuterFunction source file.

If it is used as a step in Workflow somewhere, then indeed there is no easy way to invoke one Slack Custom Function from another Slack Custom Function at this time.

There is a bit of a workaround, but this would only enable a one-way invocation of a function and you wouldn't be able to get a return value from the invoked funciton (which it seems like you are trying to achieve given your example's use of the a variable). It is also quite involved and kind of slow:

  1. Create an additional, simplistic Workflow that only wraps InnerFunction.
  2. Within OuterFunction's logic, dynamically create a Scheduled Trigger for some very-near future (i.e. now + 1 second) that will invoke the Workflow you created in step 1.
filmaj commented 10 months ago

Another idea: could you sequence a Workflow with both InnerFunction and OuterFunction so that InnerFunction runs first and its output gets used as an input to InnerFunction, which runs after OuterFunction?

markfoden commented 10 months ago

Thanks very much Fil (hope that's your preferred name) this is a really useful steer.

Can I ask you for a little more help?

My situation is that I have an app that interacts with a CRM using the OAuth features of the SDK. I've built a Slack function that queries the CRM and this works fine when called directly from a workflow.

But the app has a modal that needs to interact with the CRM in a similar way. For example the modal Slack function creates an external Select Menu block that calls an addBlockSuggestionHandler to get a filtered list of names from the CRM. Hence my question about calling a Function from within a Function.

So, from what you say, it looks like I need to build an ordinary function to query the CRM then call this from my Slack functions. Does that make sense? If so then I'm wondering what issues there are...

For example, it looks like getting the OAuth token is a feature of the SDK eg...

const tokenResponse = await client.apps.auth.external.get({
    external_token_id: inputs.accessTokenId,
});

Is there a way to implement this in an ordinary function? Are there other issues like this I'd need to deal with?

I'm learning Typescript (and if I'm honest JavaScript too) is there any sample code you could point me at that might help with this?

Thanks again

Mark

filmaj commented 10 months ago

Happy to help work through your use case! And yes, Fil is my preferred name 😄

Could you provide a bit more information on what the end-user experience/goal of the workflow is? While I understand your description of what you've achieved (pulling data from a CRM, dynamically populating menu options with this data), it is unclear to me why you want to call a function within a function in this case? If you could elaborate at a higher level what the experience you want to create from an end-user perspective is like, then I think I could better understand the requirements and provide better suggestions on how to structure the solution.

A Slack Custom Function is able to create a modal-based experience for end-users, and using the various interactivity handlers, like the blockSuggestionHandler you already seem to be using, you can create quite a rich experience. These interactivity handlers are "scoped" to a single Slack Custom Function, and with them you can pause the completion of the function "step" within a workflow until the user completes some modal-based flow - at which point you can then 'complete' the function's execution. This would then mark the step in the workflow as complete, and the workflow would move on to its next step. I think we can use this general pattern in your case to achieve what you are trying to do.

markfoden commented 10 months ago

Fil, it's kind of you to offer help

The context is about enabling our users to select and invite one of our ~250 partners to bid for a contract with us.

This is currently done in our CRM but we'd like to do it from a Slack thread we use to manage the contract.

In this case the user must:

  1. Select the partner
  2. Confirm the partner's suitability
  3. Check/update some information about the contract
  4. Initiate the sending of an email to the partner

I've been experimenting with a single modal function to handle all of this. (But perhaps I've got this wrong.)

Step 1 requires the selection of one of the 250 partners from our CRM, so a standard pre-populated menu select won't work as this is limited to 100 items.

I tried to use the Options Request URL approach but one of your colleagues on the help desk told me that this wasn't available in the new platform and that I needed to use an external select block and a suggestion handler.

The external select block passes the characters typed by the user to the handler and I was trying to find a way to use these in a query to our CRM. Hence the function within a function.

There'll be a similar issue in step 2 because after the user selects a partner there is a need to query the CRM again to get details about that partner from the CRM. I was thinking of doing this from the main modal function hence the function within a function again.

Grateful for your thoughts

filmaj commented 10 months ago

@markfoden could you do the CRM query within the block suggestion handler directly?

Another option if you want to go the static route via a Static Select Menu is to group the available partners into Option Groups. Static Select Menus support up to 100 Option Groups, and each Option Group can hold 100 options. That expands the maximum possible number of options you can use a static select menu - though it forces you to group them into (possibly arbitrary) groups.

filmaj commented 10 months ago

I have a sample application that I've been using internally to test more advanced interactivity handlers / more complex modal-based UIs. It involves using all the different kinds of interactivity handlers (block actions, view submission and closed, and block suggestions). It builds a multi-modal experience using the views.push and views.update APIs - if you haven't already seen this, check out our Modals article and particularly the Understanding the lifecycle of a modal flowchart to see how the two APIs I mentioned can factor into building such an experience.

The sample I mentioned is also entirely scoped to a single Custom Function, effectively making the entire multi-modal experience as part of a single 'step' in a Workflow. When I have a moment today I'll post it to my personal GitHub and link it here - I think it could be a useful blueprint for your use case.

The general idea is that the multi-modal experience you craft for the end-user makes up a single step in your Workflow. By having your main Custom Function return completed: false, you signal to Slack that this Function will be manually set to "complete" by your application at a future date. This allows you to 'pause' the completion of the Function step within the Workflow until something is complete - in your use case, it would be until the end-user completes filling out the various form inputs (partner selection, rendering partner details, further detailed selections based on the partner). Off the top of my head, how I would think about your use case would be: each step of the form-filling-out-process could be encapsulated within its own view in the modal stack. Once each section of the form is filled out by the user and submitted, within the view_submission handler you could retrieve relevant details from your CRM, push a new view onto the modal stack (or update the existing view in the stack) with new form inputs / rendering them in the view, and once more prompt for more information from the user. Rinse and repeat until you collect all the information you need, and as a final step, call the functions.completeSuccess API to signal to Slack that your Custom Function has completed, at which point Slack will invoke the next step in your Workflow.

Definitely have a look at the Creating an interactive modal automation documentation to see this general flow laid out. It only discusses using the view submission handler and the views.update API, but hopefully it gets the idea across.

markfoden commented 10 months ago

@filmaj Thanks, this is useful. I'll look at the detail tomorrow but on your first comment...

@markfoden could you do the CRM query within the block suggestion handler directly?

This is what I was thinking of doing. I wanted to use the characters typed by the user in the external select block, and sent to the suggestion handler, to query the CRM. Hence the need to call my CRM query Slack function within the block suggestion handler. Which I think you said can't be done right now.

So I'd need to write an ordinary function to do this. It looks like the OAuth is handled by the Slack function so I'd need either to get help on doing this or use an API key approach (which I'd prefer not to do). I can't help thinking I've misunderstood something along the way. Thanks for your patience...

filmaj commented 10 months ago

Hence the need to call my CRM query Slack function within the block suggestion handler. Which I think you said can't be done right now.

Oh, I must have mispoke. This can certainly be done today. You could use a regular fetch call to make an HTTP request to any public API you wish. You would have to add the domain of that public API to your app manifest's outgoingDomains property, but that should be the only requirement (within your app's manifest.ts).

markfoden commented 10 months ago

@filmaj Ah, thanks. And how could I get the OAuth token?

In my Slack function that calls my CRM API this is done with:

const tokenResponse = await client.apps.auth.external.get({
    external_token_id: inputs.accessTokenId,
});

But how to do it in an ordinary function?

filmaj commented 10 months ago

@markfoden assuming you are within the context of one of the Interactivity Handlers like a Block Suggestion handler, you have access to all of the same arguments that your 'main' Slack Custom Function does, including its client and inputs. So your code could look something like:

export default SlackFunction(
  MyMainFunctionDefinition,
  async ({ inputs, client }) => {
    // main function logic here...
  }
).addBlockSuggestionHandler('external-options-select-id', async ({ inputs, client }) => {
  // get the oauth token
  const tokenResponse = await client.apps.auth.external.get({
    external_token_id: inputs.accessTokenId,
  });
  // make the call to your CRM API
  const CRMresponse = await fetch(`https://mydomain.com/api/CRM?token=${tokenResponse}`);
  // return the CRM API response in a shape that is compatible with block suggestion options
  return {
    options: CRMresponse.whatever
  };
});
markfoden commented 10 months ago

@filmaj Ah! I see. Great stuff. I have got the external select menu working with OAuth now.

Would be useful to see your example too. I'm sure it will help me figure the rest out.

Thanks very much indeed

filmaj commented 10 months ago

@markfoden just did a code dump so it's a bit rough but here goes: https://github.com/filmaj/interactive-approval

If you have trouble with it or specific issues, post here or on the repo's issue list. I'll be away from my day job for the next week or so but if you at-mention me (it'll notify my phone) I'll do my best to respond.

markfoden commented 10 months ago

@filmaj Great, thank you

In case it's useful to anyone else here is my code

.addBlockSuggestionHandler({ block_id: "partner-selector", action_id: "ext_select_input"}, async ({ client, body, inputs }) => {

  // This populates an external select menu with a list of company names stored in a Zoho CRM after a user type a few characters

  // GET USER INPUT
  // characters typed into menu select block
  const searchTerm = body.value;

  // FORM REQUEST TO ZOHO CRM API
  const queryString = `select id, Account_Name from Accounts where (Account_Name like '%${searchTerm}%') and (Account_Type = 'Partner')`;
  const requestBody = {"select_query": queryString};

  // GET OAUTH TOKEN
  const tokenResponse = await client.apps.auth.external.get({external_token_id: inputs.accessTokenId});
  const externalToken = tokenResponse.external_token;

  // CALL API
  const apiUrl = "https://www.zohoapis.eu/crm/v5/coql";

  const response = await fetch(apiUrl, {
    headers: new Headers({
      "Authorization": "Zoho-oauthtoken " + externalToken,
      "Content-Type": "application/json",
    }),
    method: "POST",
    body: JSON.stringify(requestBody),
  });

  // RETURN IF NO COMPANIES FOUND
  if (response.status == 204){
    return {};
  }
  // HANDLE ERRORS
  if (response.status != 200) {
    const body = await response.text();
    const error =
      `FAIL: status: ${response.status}, body: ${body}, tokenResponse" ${tokenResponse})`;
    console.log("response error: " + error);
    return { error };
  };

  // CREATE MENU OPTIONS
  // Converts array returned in the api call to the Options array required by the menu select block
  // Format of apiResponse [{"id":"123456789", Account_Name: "Company Name"}, ...]
  const apiResponse = await response.json();
  const optionItems = Array.from(apiResponse.data, (obj: any) => ({
    text: {
    text: obj.Account_Name,
    type: "plain_text"
    },
    value: obj.id
  }));
  const menuOptions = {"options" : optionItems}

  return menuOptions;
})
filmaj commented 10 months ago

Nice! I will close this issue down, but if there's anything unresolved here, feel free to re-open or file a new issue.