-
Notifications
You must be signed in to change notification settings - Fork 77
feat: handle command injection optionally #1548
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 17 commits
971ed00
318e03c
04988f0
780c52c
84d861a
c095a38
0b3eccd
9c9644e
274793c
b72490c
ad400a8
febad52
d2d54ac
3e7229e
5983996
19355ce
ef19c9d
136eb09
0a53752
cb2ada6
114e367
fdb352b
aacb8a9
cf88672
e14c318
f17349e
d525ae3
19edce6
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,72 @@ | ||
| import type { MessageComposer } from '../../messageComposer'; | ||
| import type { | ||
| MessageComposerMiddlewareState, | ||
| MessageCompositionMiddleware, | ||
| MessageDraftComposerMiddlewareValueState, | ||
| MessageDraftCompositionMiddleware, | ||
| } from '../messageComposer/types'; | ||
| import type { MiddlewareHandlerParams } from '../../../middleware'; | ||
|
|
||
| export const createCommandInjectionMiddleware = ( | ||
| composer: MessageComposer, | ||
| ): MessageCompositionMiddleware => ({ | ||
| handlers: { | ||
| compose: ({ | ||
| forward, | ||
| next, | ||
| state, | ||
| }: MiddlewareHandlerParams<MessageComposerMiddlewareState>) => { | ||
| const command = composer.textComposer.command; | ||
| if (!command) { | ||
| return forward(); | ||
| } | ||
| const { text } = state.localMessage; | ||
|
|
||
| const injection = `/${command?.name}`; | ||
| const enrichedText = `${injection} ${text}`; | ||
|
|
||
| return next({ | ||
| ...state, | ||
| localMessage: { | ||
| ...state.localMessage, | ||
| text: enrichedText, | ||
| }, | ||
| message: { | ||
| ...state.message, | ||
| text: enrichedText, | ||
| }, | ||
| }); | ||
| }, | ||
| }, | ||
| id: 'stream-io/message-composer-middleware/command-string-injection', | ||
| }); | ||
|
|
||
| export const createDraftCommandInjectionMiddleware = ( | ||
| composer: MessageComposer, | ||
| ): MessageDraftCompositionMiddleware => ({ | ||
| handlers: { | ||
| compose: ({ | ||
| forward, | ||
| next, | ||
| state, | ||
| }: MiddlewareHandlerParams<MessageDraftComposerMiddlewareValueState>) => { | ||
| const command = composer.textComposer.command; | ||
| if (!command) { | ||
| return forward(); | ||
| } | ||
| const { text } = state.draft; | ||
|
|
||
| const injection = `/${command?.name}`; | ||
| const enrichedText = `${injection} ${text}`; | ||
|
|
||
| return next({ | ||
| ...state, | ||
| draft: { | ||
| ...state.draft, | ||
| text: enrichedText, | ||
| }, | ||
| }); | ||
| }, | ||
| }, | ||
| id: 'stream-io/message-composer-middleware/draft-command-string-injection', | ||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,55 @@ | ||
| import type { Middleware } from '../../../middleware'; | ||
| import type { TextComposerMiddlewareExecutorState } from './TextComposerMiddlewareExecutor'; | ||
| import { stripTriggerFromText } from './textMiddlewareUtils'; | ||
| import type { CommandSuggestion } from './types'; | ||
|
|
||
| export type ApplyCommandSettingsMiddleware = Middleware< | ||
| TextComposerMiddlewareExecutorState<CommandSuggestion>, | ||
| 'onChange' | 'onSuggestionItemSelect' | ||
| >; | ||
|
|
||
| export const createCommandStringExtractionMiddleware = | ||
| (): ApplyCommandSettingsMiddleware => ({ | ||
| handlers: { | ||
| onChange: ({ complete, forward, state }) => { | ||
| const { command } = state; | ||
|
|
||
| if (!command) { | ||
MartinCupela marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| return forward(); | ||
| } | ||
|
|
||
| const triggerWithCommand = `/${command.name}`; | ||
|
|
||
| const newText = stripTriggerFromText(state.text, triggerWithCommand); | ||
|
|
||
| return complete({ | ||
| ...state, | ||
| selection: { | ||
| end: state.selection.end - triggerWithCommand.length, | ||
| start: state.selection.start - triggerWithCommand.length, | ||
| }, | ||
| text: newText, | ||
| }); | ||
| }, | ||
| onSuggestionItemSelect: ({ next, forward, state }) => { | ||
| const { command } = state; | ||
|
|
||
| if (!command) { | ||
| return forward(); | ||
| } | ||
|
|
||
| const triggerWithCommand = `/${command?.name} `; | ||
|
|
||
| const newText = state.text.slice(triggerWithCommand.length); | ||
| return next({ | ||
| ...state, | ||
| selection: { | ||
| end: state.selection.end - triggerWithCommand.length, | ||
| start: state.selection.start - triggerWithCommand.length, | ||
| }, | ||
| text: newText, | ||
| }); | ||
| }, | ||
| }, | ||
| id: 'stream-io/text-composer/apply-command-settings', | ||
khushal87 marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -5,7 +5,12 @@ import { BaseSearchSourceSync } from '../../../search'; | |
| import type { CommandResponse } from '../../../types'; | ||
| import { mergeWith } from '../../../utils/mergeWith'; | ||
| import type { CommandSuggestion, TextComposerMiddlewareOptions } from './types'; | ||
| import { getTriggerCharWithToken, insertItemWithTrigger } from './textMiddlewareUtils'; | ||
| import { | ||
| getFirstWordFromText, | ||
| getTriggerCharWithToken, | ||
| insertItemWithTrigger, | ||
| isTextMatched, | ||
| } from './textMiddlewareUtils'; | ||
| import type { TextComposerMiddlewareExecutorState } from './TextComposerMiddlewareExecutor'; | ||
|
|
||
| export class CommandSearchSource extends BaseSearchSourceSync<CommandSuggestion> { | ||
|
|
@@ -115,10 +120,11 @@ export const createCommandsMiddleware = ( | |
| handlers: { | ||
| onChange: ({ state, next, complete, forward }) => { | ||
| if (!state.selection) return forward(); | ||
| const finalText = state.text.slice(0, state.selection.end); | ||
|
|
||
| const triggerWithToken = getTriggerCharWithToken({ | ||
| trigger: finalOptions.trigger, | ||
| text: state.text.slice(0, state.selection.end), | ||
| text: finalText, | ||
| acceptTrailingSpaces: false, | ||
| isCommand: true, | ||
| }); | ||
|
|
@@ -142,29 +148,49 @@ export const createCommandsMiddleware = ( | |
| return next(newState); | ||
| } | ||
|
|
||
| const query = triggerWithToken.slice(1); | ||
|
|
||
| const searchQuery = getFirstWordFromText(query); | ||
|
||
|
|
||
| const commands = searchSource?.query(searchQuery).items; | ||
|
|
||
| const matchedCommand = commands?.find((command) => | ||
|
||
| isTextMatched(query.toLowerCase(), command.name.toLowerCase()), | ||
| ); | ||
|
|
||
| if (matchedCommand) { | ||
MartinCupela marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| return next({ | ||
| ...state, | ||
| command: matchedCommand, | ||
| suggestions: undefined, | ||
| }); | ||
| } | ||
|
|
||
| return complete({ | ||
| ...state, | ||
| command: null, | ||
| suggestions: { | ||
| query: triggerWithToken.slice(1), | ||
| query: searchQuery, | ||
| searchSource, | ||
| trigger: finalOptions.trigger, | ||
| }, | ||
| }); | ||
| }, | ||
| onSuggestionItemSelect: ({ state, complete, forward }) => { | ||
| onSuggestionItemSelect: ({ state, next, forward }) => { | ||
| const { selectedSuggestion } = state.change ?? {}; | ||
| if (!selectedSuggestion || state.suggestions?.trigger !== finalOptions.trigger) | ||
| return forward(); | ||
|
|
||
| searchSource.resetStateAndActivate(); | ||
| return complete({ | ||
| return next({ | ||
| ...state, | ||
| ...insertItemWithTrigger({ | ||
| insertText: `/${selectedSuggestion.name} `, | ||
| selection: state.selection, | ||
| text: state.text, | ||
| trigger: finalOptions.trigger, | ||
| }), | ||
| command: selectedSuggestion, | ||
| suggestions: undefined, | ||
| }); | ||
| }, | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,5 +1,10 @@ | ||
| import type { TextSelection } from './types'; | ||
|
|
||
| /** | ||
| * For commands, we want to match all patterns except: | ||
| * 1. Text not starting with trigger | ||
| * 2. Trigger in middle of text | ||
| */ | ||
| export const getTriggerCharWithToken = ({ | ||
| trigger, | ||
| text, | ||
|
|
@@ -11,19 +16,23 @@ export const getTriggerCharWithToken = ({ | |
| isCommand?: boolean; | ||
| acceptTrailingSpaces?: boolean; | ||
| }) => { | ||
| // No trigger in between text | ||
| const notTrigger = `[^${trigger}]*`; | ||
| const triggerNorWhitespace = `[^\\s${trigger}]*`; | ||
|
|
||
| const match = text.match( | ||
| new RegExp( | ||
| isCommand | ||
| ? `^[${trigger}]${triggerNorWhitespace}$` | ||
| ? `^[${trigger}]${notTrigger}$` | ||
|
||
| : acceptTrailingSpaces | ||
| ? `(?!^|\\W)?[${trigger}]${triggerNorWhitespace}\\s?${triggerNorWhitespace}$` | ||
| : `(?!^|\\W)?[${trigger}]${triggerNorWhitespace}$`, | ||
| 'g', | ||
| ), | ||
| ); | ||
|
|
||
| return match && match[match.length - 1].trim(); | ||
| const result = match && match[match.length - 1]; | ||
| return isCommand ? result : result?.trim(); | ||
| }; | ||
|
|
||
| export const insertItemWithTrigger = ({ | ||
|
|
@@ -134,3 +143,33 @@ export const getTokenizedSuggestionDisplayName = ({ | |
| : [displayName], | ||
| }, | ||
| }); | ||
|
|
||
| export const isTextMatched = (input: string, command: string): boolean => { | ||
|
||
| try { | ||
| // Create a regex to match the command at the start of the input with optional whitespace | ||
| const regex = new RegExp(`^${escapeRegExp(command)}\\s+`, 'i'); | ||
| return regex.test(input); | ||
| } catch (error) { | ||
| console.error('Error in validating with the regex:', error); | ||
| return false; | ||
| } | ||
| }; | ||
|
|
||
| /** | ||
| * Extracts the first word from a given text. | ||
| * @param text - The input text from which to extract the first word. | ||
| * @returns The first word found in the text, or an empty string if no word is found. | ||
| */ | ||
| export const getFirstWordFromText = (text: string): string => { | ||
|
||
| const match = text.match(/^\s*(\S+)/); | ||
| return match ? match[1] : ''; | ||
| }; | ||
|
|
||
| /** | ||
| * Strips the trigger from the text. | ||
| * @param text - The input text from which to strip the trigger. | ||
| * @param trigger - The trigger string to be removed from the start of the text. | ||
| * @returns The text with the trigger removed from the start. | ||
| */ | ||
| export const stripTriggerFromText = (text: string, trigger: string) => | ||
|
||
| text.replace(new RegExp(`^${escapeRegExp(trigger)}\\s*`), ''); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -6,7 +6,7 @@ import type { TextSelection } from './middleware/textComposer/types'; | |
| import type { TextComposerState } from './middleware/textComposer/types'; | ||
| import type { Suggestions } from './middleware/textComposer/types'; | ||
| import type { MessageComposer } from './messageComposer'; | ||
| import type { DraftMessage, LocalMessage, UserResponse } from '../types'; | ||
| import type { CommandResponse, DraftMessage, LocalMessage, UserResponse } from '../types'; | ||
|
|
||
| export type TextComposerOptions = { | ||
| composer: MessageComposer; | ||
|
|
@@ -37,6 +37,7 @@ const initState = ({ | |
| if (!message) { | ||
| const text = composer.config.text.defaultValue ?? ''; | ||
| return { | ||
| command: null, | ||
| mentionedUsers: [], | ||
| text, | ||
| selection: { start: text.length, end: text.length }, | ||
|
|
@@ -118,6 +119,10 @@ export class TextComposer { | |
|
|
||
| // --- START STATE API --- | ||
|
|
||
| get command() { | ||
| return this.state.getLatestValue().command; | ||
| } | ||
|
|
||
| get mentionedUsers() { | ||
| return this.state.getLatestValue().mentionedUsers; | ||
| } | ||
|
|
@@ -146,6 +151,10 @@ export class TextComposer { | |
| this.state.partialNext({ mentionedUsers: users }); | ||
| } | ||
|
|
||
| clearCommand() { | ||
| this.state.partialNext({ command: null }); | ||
| } | ||
|
|
||
| upsertMentionedUser = (user: UserResponse) => { | ||
| const mentionedUsers = [...this.mentionedUsers]; | ||
| const existingUserIndex = mentionedUsers.findIndex((u) => u.id === user.id); | ||
|
|
@@ -169,6 +178,11 @@ export class TextComposer { | |
| this.state.partialNext({ mentionedUsers }); | ||
| }; | ||
|
|
||
| setCommand = (command: CommandResponse | null) => { | ||
| if (command === this.command) return; | ||
|
||
| this.state.partialNext({ command }); | ||
| }; | ||
|
|
||
| setText = (text: string) => { | ||
| if (!this.enabled || text === this.text) return; | ||
| this.state.partialNext({ text }); | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.