@ai-devkit/channel-connector 0.10.1 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/README.md +4 -1
  2. package/dist/ConfigStore.d.ts.map +1 -1
  3. package/dist/ConfigStore.js +1 -0
  4. package/dist/ConfigStore.js.map +1 -1
  5. package/dist/__tests__/ChannelManager.test.js +3 -1
  6. package/dist/__tests__/ChannelManager.test.js.map +1 -1
  7. package/dist/__tests__/ConfigStore.test.js +33 -4
  8. package/dist/__tests__/ConfigStore.test.js.map +1 -1
  9. package/dist/__tests__/adapters/SlackAdapter.test.js +429 -0
  10. package/dist/__tests__/adapters/SlackAdapter.test.js.map +1 -0
  11. package/dist/__tests__/adapters/TelegramAdapter.test.js +131 -0
  12. package/dist/__tests__/adapters/TelegramAdapter.test.js.map +1 -1
  13. package/dist/__tests__/utils/SlackDeliveryQueue.test.js +127 -0
  14. package/dist/__tests__/utils/SlackDeliveryQueue.test.js.map +1 -0
  15. package/dist/__tests__/utils/slackMarkdown.test.js +44 -0
  16. package/dist/__tests__/utils/slackMarkdown.test.js.map +1 -0
  17. package/dist/adapters/ChannelAdapter.d.ts +8 -2
  18. package/dist/adapters/ChannelAdapter.d.ts.map +1 -1
  19. package/dist/adapters/ChannelAdapter.js +3 -6
  20. package/dist/adapters/ChannelAdapter.js.map +1 -1
  21. package/dist/adapters/SlackAdapter.d.ts +71 -0
  22. package/dist/adapters/SlackAdapter.d.ts.map +1 -0
  23. package/dist/adapters/SlackAdapter.js +205 -0
  24. package/dist/adapters/SlackAdapter.js.map +1 -0
  25. package/dist/adapters/TelegramAdapter.d.ts +19 -1
  26. package/dist/adapters/TelegramAdapter.d.ts.map +1 -1
  27. package/dist/adapters/TelegramAdapter.js +69 -0
  28. package/dist/adapters/TelegramAdapter.js.map +1 -1
  29. package/dist/index.d.ts +7 -2
  30. package/dist/index.d.ts.map +1 -1
  31. package/dist/index.js +4 -0
  32. package/dist/index.js.map +1 -1
  33. package/dist/types.d.ts +86 -2
  34. package/dist/types.d.ts.map +1 -1
  35. package/dist/types.js +3 -3
  36. package/dist/types.js.map +1 -1
  37. package/dist/utils/SlackDeliveryQueue.d.ts +33 -0
  38. package/dist/utils/SlackDeliveryQueue.d.ts.map +1 -0
  39. package/dist/utils/SlackDeliveryQueue.js +83 -0
  40. package/dist/utils/SlackDeliveryQueue.js.map +1 -0
  41. package/dist/utils/slackMarkdown.d.ts +5 -0
  42. package/dist/utils/slackMarkdown.d.ts.map +1 -0
  43. package/dist/utils/slackMarkdown.js +150 -0
  44. package/dist/utils/slackMarkdown.js.map +1 -0
  45. package/package.json +3 -1
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Bridge AI DevKit agent sessions to external messaging channels.
4
4
 
5
- This package powers the `ai-devkit channel` commands. Use it when you need the lower-level connector layer that routes messages between running AI coding agents and channels such as Telegram.
5
+ This package powers the `ai-devkit channel` commands. Use it when you need the lower-level connector layer that routes messages between running AI coding agents and channels such as Telegram and private Slack DMs.
6
6
 
7
7
  ## What It Provides
8
8
 
@@ -17,11 +17,14 @@ Most users should use the CLI:
17
17
 
18
18
  ```bash
19
19
  ai-devkit channel connect telegram
20
+ ai-devkit channel connect slack --name work-slack
20
21
  ai-devkit channel start --agent <agent-name>
21
22
  ```
22
23
 
23
24
  Use this package directly only when building custom channel integrations or extending AI DevKit's remote-control surface.
24
25
 
26
+ Slack uses the official `@slack/socket-mode` and `@slack/web-api` clients. The current proof of concept is a user-owned, single-workspace, DM-only Socket Mode app without Slack-user authorization. Use it only in a disposable/private workspace with a non-sensitive agent session. Public channels, OAuth distribution, files, and multi-workspace routing are not supported.
27
+
25
28
  ## Documentation
26
29
 
27
30
  Full guides and workflow examples: **[ai-devkit.com/docs](https://ai-devkit.com/docs/)**
@@ -1 +1 @@
1
- {"version":3,"file":"ConfigStore.d.ts","sourceRoot":"","sources":["../src/ConfigStore.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,aAAa,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC;AAK9D;;;;GAIG;AACH,qBAAa,WAAW;IACpB,OAAO,CAAC,UAAU,CAAS;gBAEf,UAAU,CAAC,EAAE,MAAM;IAI/B;;OAEG;IACG,SAAS,IAAI,OAAO,CAAC,aAAa,CAAC;IASzC;;OAEG;IACG,WAAW,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,YAAY,GAAG,OAAO,CAAC,IAAI,CAAC;IAMnE;;OAEG;IACG,aAAa,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAMhD;;OAEG;IACG,UAAU,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,GAAG,SAAS,CAAC;YAKnD,WAAW;CAK5B"}
1
+ {"version":3,"file":"ConfigStore.d.ts","sourceRoot":"","sources":["../src/ConfigStore.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,aAAa,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC;AAK9D;;;;GAIG;AACH,qBAAa,WAAW;IACpB,OAAO,CAAC,UAAU,CAAS;gBAEf,UAAU,CAAC,EAAE,MAAM;IAI/B;;OAEG;IACG,SAAS,IAAI,OAAO,CAAC,aAAa,CAAC;IASzC;;OAEG;IACG,WAAW,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,YAAY,GAAG,OAAO,CAAC,IAAI,CAAC;IAMnE;;OAEG;IACG,aAAa,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAMhD;;OAEG;IACG,UAAU,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,GAAG,SAAS,CAAC;YAKnD,WAAW;CAM5B"}
@@ -55,6 +55,7 @@ const DEFAULT_CONFIG = {
55
55
  fs.writeFileSync(this.configPath, JSON.stringify(config, null, 2), {
56
56
  mode: 0o600
57
57
  });
58
+ fs.chmodSync(this.configPath, 0o600);
58
59
  }
59
60
  }
60
61
 
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/ConfigStore.ts"],"sourcesContent":["import * as fs from 'fs';\nimport * as path from 'path';\nimport * as os from 'os';\nimport type { ChannelConfig, ChannelEntry } from './types.js';\n\nconst DEFAULT_CONFIG_PATH = path.join(os.homedir(), '.ai-devkit', 'channels.json');\nconst DEFAULT_CONFIG: ChannelConfig = { channels: {} };\n\n/**\n * Persists channel configurations to disk.\n * Default location: ~/.ai-devkit/channels.json\n * File permissions are set to 0600 to protect tokens.\n */\nexport class ConfigStore {\n private configPath: string;\n\n constructor(configPath?: string) {\n this.configPath = configPath ?? DEFAULT_CONFIG_PATH;\n }\n\n /**\n * Read the full config. Returns default empty config if file is missing or corrupt.\n */\n async getConfig(): Promise<ChannelConfig> {\n try {\n const raw = fs.readFileSync(this.configPath, 'utf-8');\n return JSON.parse(raw) as ChannelConfig;\n } catch {\n return { ...DEFAULT_CONFIG, channels: {} };\n }\n }\n\n /**\n * Save a channel entry. Creates the file and parent directory if needed.\n */\n async saveChannel(name: string, entry: ChannelEntry): Promise<void> {\n const config = await this.getConfig();\n config.channels[name] = entry;\n await this.writeConfig(config);\n }\n\n /**\n * Remove a channel entry by name.\n */\n async removeChannel(name: string): Promise<void> {\n const config = await this.getConfig();\n delete config.channels[name];\n await this.writeConfig(config);\n }\n\n /**\n * Get a single channel entry by name.\n */\n async getChannel(name: string): Promise<ChannelEntry | undefined> {\n const config = await this.getConfig();\n return config.channels[name];\n }\n\n private async writeConfig(config: ChannelConfig): Promise<void> {\n const dir = path.dirname(this.configPath);\n fs.mkdirSync(dir, { recursive: true });\n fs.writeFileSync(this.configPath, JSON.stringify(config, null, 2), { mode: 0o600 });\n }\n}\n"],"names":["fs","path","os","DEFAULT_CONFIG_PATH","join","homedir","DEFAULT_CONFIG","channels","ConfigStore","configPath","getConfig","raw","readFileSync","JSON","parse","saveChannel","name","entry","config","writeConfig","removeChannel","getChannel","dir","dirname","mkdirSync","recursive","writeFileSync","stringify","mode"],"mappings":"AAAA,YAAYA,QAAQ,KAAK;AACzB,YAAYC,UAAU,OAAO;AAC7B,YAAYC,QAAQ,KAAK;AAGzB,MAAMC,sBAAsBF,KAAKG,IAAI,CAACF,GAAGG,OAAO,IAAI,cAAc;AAClE,MAAMC,iBAAgC;IAAEC,UAAU,CAAC;AAAE;AAErD;;;;CAIC,GACD,OAAO,MAAMC;IACDC,WAAmB;IAE3B,YAAYA,UAAmB,CAAE;QAC7B,IAAI,CAACA,UAAU,GAAGA,cAAcN;IACpC;IAEA;;KAEC,GACD,MAAMO,YAAoC;QACtC,IAAI;YACA,MAAMC,MAAMX,GAAGY,YAAY,CAAC,IAAI,CAACH,UAAU,EAAE;YAC7C,OAAOI,KAAKC,KAAK,CAACH;QACtB,EAAE,OAAM;YACJ,OAAO;gBAAE,GAAGL,cAAc;gBAAEC,UAAU,CAAC;YAAE;QAC7C;IACJ;IAEA;;KAEC,GACD,MAAMQ,YAAYC,IAAY,EAAEC,KAAmB,EAAiB;QAChE,MAAMC,SAAS,MAAM,IAAI,CAACR,SAAS;QACnCQ,OAAOX,QAAQ,CAACS,KAAK,GAAGC;QACxB,MAAM,IAAI,CAACE,WAAW,CAACD;IAC3B;IAEA;;KAEC,GACD,MAAME,cAAcJ,IAAY,EAAiB;QAC7C,MAAME,SAAS,MAAM,IAAI,CAACR,SAAS;QACnC,OAAOQ,OAAOX,QAAQ,CAACS,KAAK;QAC5B,MAAM,IAAI,CAACG,WAAW,CAACD;IAC3B;IAEA;;KAEC,GACD,MAAMG,WAAWL,IAAY,EAAqC;QAC9D,MAAME,SAAS,MAAM,IAAI,CAACR,SAAS;QACnC,OAAOQ,OAAOX,QAAQ,CAACS,KAAK;IAChC;IAEA,MAAcG,YAAYD,MAAqB,EAAiB;QAC5D,MAAMI,MAAMrB,KAAKsB,OAAO,CAAC,IAAI,CAACd,UAAU;QACxCT,GAAGwB,SAAS,CAACF,KAAK;YAAEG,WAAW;QAAK;QACpCzB,GAAG0B,aAAa,CAAC,IAAI,CAACjB,UAAU,EAAEI,KAAKc,SAAS,CAACT,QAAQ,MAAM,IAAI;YAAEU,MAAM;QAAM;IACrF;AACJ"}
1
+ {"version":3,"sources":["../src/ConfigStore.ts"],"sourcesContent":["import * as fs from 'fs';\nimport * as path from 'path';\nimport * as os from 'os';\nimport type { ChannelConfig, ChannelEntry } from './types.js';\n\nconst DEFAULT_CONFIG_PATH = path.join(os.homedir(), '.ai-devkit', 'channels.json');\nconst DEFAULT_CONFIG: ChannelConfig = { channels: {} };\n\n/**\n * Persists channel configurations to disk.\n * Default location: ~/.ai-devkit/channels.json\n * File permissions are set to 0600 to protect tokens.\n */\nexport class ConfigStore {\n private configPath: string;\n\n constructor(configPath?: string) {\n this.configPath = configPath ?? DEFAULT_CONFIG_PATH;\n }\n\n /**\n * Read the full config. Returns default empty config if file is missing or corrupt.\n */\n async getConfig(): Promise<ChannelConfig> {\n try {\n const raw = fs.readFileSync(this.configPath, 'utf-8');\n return JSON.parse(raw) as ChannelConfig;\n } catch {\n return { ...DEFAULT_CONFIG, channels: {} };\n }\n }\n\n /**\n * Save a channel entry. Creates the file and parent directory if needed.\n */\n async saveChannel(name: string, entry: ChannelEntry): Promise<void> {\n const config = await this.getConfig();\n config.channels[name] = entry;\n await this.writeConfig(config);\n }\n\n /**\n * Remove a channel entry by name.\n */\n async removeChannel(name: string): Promise<void> {\n const config = await this.getConfig();\n delete config.channels[name];\n await this.writeConfig(config);\n }\n\n /**\n * Get a single channel entry by name.\n */\n async getChannel(name: string): Promise<ChannelEntry | undefined> {\n const config = await this.getConfig();\n return config.channels[name];\n }\n\n private async writeConfig(config: ChannelConfig): Promise<void> {\n const dir = path.dirname(this.configPath);\n fs.mkdirSync(dir, { recursive: true });\n fs.writeFileSync(this.configPath, JSON.stringify(config, null, 2), { mode: 0o600 });\n fs.chmodSync(this.configPath, 0o600);\n }\n}\n"],"names":["fs","path","os","DEFAULT_CONFIG_PATH","join","homedir","DEFAULT_CONFIG","channels","ConfigStore","configPath","getConfig","raw","readFileSync","JSON","parse","saveChannel","name","entry","config","writeConfig","removeChannel","getChannel","dir","dirname","mkdirSync","recursive","writeFileSync","stringify","mode","chmodSync"],"mappings":"AAAA,YAAYA,QAAQ,KAAK;AACzB,YAAYC,UAAU,OAAO;AAC7B,YAAYC,QAAQ,KAAK;AAGzB,MAAMC,sBAAsBF,KAAKG,IAAI,CAACF,GAAGG,OAAO,IAAI,cAAc;AAClE,MAAMC,iBAAgC;IAAEC,UAAU,CAAC;AAAE;AAErD;;;;CAIC,GACD,OAAO,MAAMC;IACDC,WAAmB;IAE3B,YAAYA,UAAmB,CAAE;QAC7B,IAAI,CAACA,UAAU,GAAGA,cAAcN;IACpC;IAEA;;KAEC,GACD,MAAMO,YAAoC;QACtC,IAAI;YACA,MAAMC,MAAMX,GAAGY,YAAY,CAAC,IAAI,CAACH,UAAU,EAAE;YAC7C,OAAOI,KAAKC,KAAK,CAACH;QACtB,EAAE,OAAM;YACJ,OAAO;gBAAE,GAAGL,cAAc;gBAAEC,UAAU,CAAC;YAAE;QAC7C;IACJ;IAEA;;KAEC,GACD,MAAMQ,YAAYC,IAAY,EAAEC,KAAmB,EAAiB;QAChE,MAAMC,SAAS,MAAM,IAAI,CAACR,SAAS;QACnCQ,OAAOX,QAAQ,CAACS,KAAK,GAAGC;QACxB,MAAM,IAAI,CAACE,WAAW,CAACD;IAC3B;IAEA;;KAEC,GACD,MAAME,cAAcJ,IAAY,EAAiB;QAC7C,MAAME,SAAS,MAAM,IAAI,CAACR,SAAS;QACnC,OAAOQ,OAAOX,QAAQ,CAACS,KAAK;QAC5B,MAAM,IAAI,CAACG,WAAW,CAACD;IAC3B;IAEA;;KAEC,GACD,MAAMG,WAAWL,IAAY,EAAqC;QAC9D,MAAME,SAAS,MAAM,IAAI,CAACR,SAAS;QACnC,OAAOQ,OAAOX,QAAQ,CAACS,KAAK;IAChC;IAEA,MAAcG,YAAYD,MAAqB,EAAiB;QAC5D,MAAMI,MAAMrB,KAAKsB,OAAO,CAAC,IAAI,CAACd,UAAU;QACxCT,GAAGwB,SAAS,CAACF,KAAK;YAAEG,WAAW;QAAK;QACpCzB,GAAG0B,aAAa,CAAC,IAAI,CAACjB,UAAU,EAAEI,KAAKc,SAAS,CAACT,QAAQ,MAAM,IAAI;YAAEU,MAAM;QAAM;QACjF5B,GAAG6B,SAAS,CAAC,IAAI,CAACpB,UAAU,EAAE;IAClC;AACJ"}
@@ -4,7 +4,9 @@ function createMockAdapter(type) {
4
4
  type,
5
5
  start: vi.fn().mockResolvedValue(undefined),
6
6
  stop: vi.fn().mockResolvedValue(undefined),
7
- sendMessage: vi.fn().mockResolvedValue(undefined),
7
+ sendMessage: vi.fn().mockResolvedValue({
8
+ messageId: '1'
9
+ }),
8
10
  onMessage: vi.fn(),
9
11
  isHealthy: vi.fn().mockResolvedValue(true)
10
12
  };
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/__tests__/ChannelManager.test.ts"],"sourcesContent":["import type { Mocked } from 'vitest';\nimport { ChannelManager } from '../ChannelManager.js';\nimport type { ChannelAdapter } from '../adapters/ChannelAdapter.js';\n\nfunction createMockAdapter(type: string): Mocked<ChannelAdapter> {\n return {\n type,\n start: vi.fn().mockResolvedValue(undefined),\n stop: vi.fn().mockResolvedValue(undefined),\n sendMessage: vi.fn().mockResolvedValue(undefined),\n onMessage: vi.fn(),\n isHealthy: vi.fn().mockResolvedValue(true),\n };\n}\n\ndescribe('ChannelManager', () => {\n let manager: ChannelManager;\n\n beforeEach(() => {\n manager = new ChannelManager();\n });\n\n describe('registerAdapter', () => {\n it('should register an adapter', () => {\n const adapter = createMockAdapter('telegram');\n manager.registerAdapter(adapter);\n expect(manager.getAdapter('telegram')).toBe(adapter);\n });\n\n it('should throw on duplicate adapter type', () => {\n const adapter1 = createMockAdapter('telegram');\n const adapter2 = createMockAdapter('telegram');\n manager.registerAdapter(adapter1);\n expect(() => manager.registerAdapter(adapter2)).toThrow(\n 'Adapter for type \"telegram\" is already registered'\n );\n });\n });\n\n describe('getAdapter', () => {\n it('should return undefined for unregistered type', () => {\n expect(manager.getAdapter('slack')).toBeUndefined();\n });\n\n it('should return the registered adapter', () => {\n const adapter = createMockAdapter('telegram');\n manager.registerAdapter(adapter);\n expect(manager.getAdapter('telegram')).toBe(adapter);\n });\n });\n\n describe('startAll', () => {\n it('should call start() on all registered adapters', async () => {\n const telegram = createMockAdapter('telegram');\n const slack = createMockAdapter('slack');\n manager.registerAdapter(telegram);\n manager.registerAdapter(slack);\n\n await manager.startAll();\n\n expect(telegram.start).toHaveBeenCalledTimes(1);\n expect(slack.start).toHaveBeenCalledTimes(1);\n });\n\n it('should work with no adapters', async () => {\n await expect(manager.startAll()).resolves.toBeUndefined();\n });\n });\n\n describe('stopAll', () => {\n it('should call stop() on all registered adapters', async () => {\n const telegram = createMockAdapter('telegram');\n const slack = createMockAdapter('slack');\n manager.registerAdapter(telegram);\n manager.registerAdapter(slack);\n\n await manager.stopAll();\n\n expect(telegram.stop).toHaveBeenCalledTimes(1);\n expect(slack.stop).toHaveBeenCalledTimes(1);\n });\n\n it('should work with no adapters', async () => {\n await expect(manager.stopAll()).resolves.toBeUndefined();\n });\n });\n});\n"],"names":["ChannelManager","createMockAdapter","type","start","vi","fn","mockResolvedValue","undefined","stop","sendMessage","onMessage","isHealthy","describe","manager","beforeEach","it","adapter","registerAdapter","expect","getAdapter","toBe","adapter1","adapter2","toThrow","toBeUndefined","telegram","slack","startAll","toHaveBeenCalledTimes","resolves","stopAll"],"mappings":"AACA,SAASA,cAAc,QAAQ,uBAAuB;AAGtD,SAASC,kBAAkBC,IAAY;IACnC,OAAO;QACHA;QACAC,OAAOC,GAAGC,EAAE,GAAGC,iBAAiB,CAACC;QACjCC,MAAMJ,GAAGC,EAAE,GAAGC,iBAAiB,CAACC;QAChCE,aAAaL,GAAGC,EAAE,GAAGC,iBAAiB,CAACC;QACvCG,WAAWN,GAAGC,EAAE;QAChBM,WAAWP,GAAGC,EAAE,GAAGC,iBAAiB,CAAC;IACzC;AACJ;AAEAM,SAAS,kBAAkB;IACvB,IAAIC;IAEJC,WAAW;QACPD,UAAU,IAAIb;IAClB;IAEAY,SAAS,mBAAmB;QACxBG,GAAG,8BAA8B;YAC7B,MAAMC,UAAUf,kBAAkB;YAClCY,QAAQI,eAAe,CAACD;YACxBE,OAAOL,QAAQM,UAAU,CAAC,aAAaC,IAAI,CAACJ;QAChD;QAEAD,GAAG,0CAA0C;YACzC,MAAMM,WAAWpB,kBAAkB;YACnC,MAAMqB,WAAWrB,kBAAkB;YACnCY,QAAQI,eAAe,CAACI;YACxBH,OAAO,IAAML,QAAQI,eAAe,CAACK,WAAWC,OAAO,CACnD;QAER;IACJ;IAEAX,SAAS,cAAc;QACnBG,GAAG,iDAAiD;YAChDG,OAAOL,QAAQM,UAAU,CAAC,UAAUK,aAAa;QACrD;QAEAT,GAAG,wCAAwC;YACvC,MAAMC,UAAUf,kBAAkB;YAClCY,QAAQI,eAAe,CAACD;YACxBE,OAAOL,QAAQM,UAAU,CAAC,aAAaC,IAAI,CAACJ;QAChD;IACJ;IAEAJ,SAAS,YAAY;QACjBG,GAAG,kDAAkD;YACjD,MAAMU,WAAWxB,kBAAkB;YACnC,MAAMyB,QAAQzB,kBAAkB;YAChCY,QAAQI,eAAe,CAACQ;YACxBZ,QAAQI,eAAe,CAACS;YAExB,MAAMb,QAAQc,QAAQ;YAEtBT,OAAOO,SAAStB,KAAK,EAAEyB,qBAAqB,CAAC;YAC7CV,OAAOQ,MAAMvB,KAAK,EAAEyB,qBAAqB,CAAC;QAC9C;QAEAb,GAAG,gCAAgC;YAC/B,MAAMG,OAAOL,QAAQc,QAAQ,IAAIE,QAAQ,CAACL,aAAa;QAC3D;IACJ;IAEAZ,SAAS,WAAW;QAChBG,GAAG,iDAAiD;YAChD,MAAMU,WAAWxB,kBAAkB;YACnC,MAAMyB,QAAQzB,kBAAkB;YAChCY,QAAQI,eAAe,CAACQ;YACxBZ,QAAQI,eAAe,CAACS;YAExB,MAAMb,QAAQiB,OAAO;YAErBZ,OAAOO,SAASjB,IAAI,EAAEoB,qBAAqB,CAAC;YAC5CV,OAAOQ,MAAMlB,IAAI,EAAEoB,qBAAqB,CAAC;QAC7C;QAEAb,GAAG,gCAAgC;YAC/B,MAAMG,OAAOL,QAAQiB,OAAO,IAAID,QAAQ,CAACL,aAAa;QAC1D;IACJ;AACJ"}
1
+ {"version":3,"sources":["../../src/__tests__/ChannelManager.test.ts"],"sourcesContent":["import type { Mocked } from 'vitest';\nimport { ChannelManager } from '../ChannelManager.js';\nimport type { ChannelAdapter } from '../adapters/ChannelAdapter.js';\n\nfunction createMockAdapter(type: string): Mocked<ChannelAdapter> {\n return {\n type,\n start: vi.fn().mockResolvedValue(undefined),\n stop: vi.fn().mockResolvedValue(undefined),\n sendMessage: vi.fn().mockResolvedValue({ messageId: '1' }),\n onMessage: vi.fn(),\n isHealthy: vi.fn().mockResolvedValue(true),\n };\n}\n\ndescribe('ChannelManager', () => {\n let manager: ChannelManager;\n\n beforeEach(() => {\n manager = new ChannelManager();\n });\n\n describe('registerAdapter', () => {\n it('should register an adapter', () => {\n const adapter = createMockAdapter('telegram');\n manager.registerAdapter(adapter);\n expect(manager.getAdapter('telegram')).toBe(adapter);\n });\n\n it('should throw on duplicate adapter type', () => {\n const adapter1 = createMockAdapter('telegram');\n const adapter2 = createMockAdapter('telegram');\n manager.registerAdapter(adapter1);\n expect(() => manager.registerAdapter(adapter2)).toThrow(\n 'Adapter for type \"telegram\" is already registered'\n );\n });\n });\n\n describe('getAdapter', () => {\n it('should return undefined for unregistered type', () => {\n expect(manager.getAdapter('slack')).toBeUndefined();\n });\n\n it('should return the registered adapter', () => {\n const adapter = createMockAdapter('telegram');\n manager.registerAdapter(adapter);\n expect(manager.getAdapter('telegram')).toBe(adapter);\n });\n });\n\n describe('startAll', () => {\n it('should call start() on all registered adapters', async () => {\n const telegram = createMockAdapter('telegram');\n const slack = createMockAdapter('slack');\n manager.registerAdapter(telegram);\n manager.registerAdapter(slack);\n\n await manager.startAll();\n\n expect(telegram.start).toHaveBeenCalledTimes(1);\n expect(slack.start).toHaveBeenCalledTimes(1);\n });\n\n it('should work with no adapters', async () => {\n await expect(manager.startAll()).resolves.toBeUndefined();\n });\n });\n\n describe('stopAll', () => {\n it('should call stop() on all registered adapters', async () => {\n const telegram = createMockAdapter('telegram');\n const slack = createMockAdapter('slack');\n manager.registerAdapter(telegram);\n manager.registerAdapter(slack);\n\n await manager.stopAll();\n\n expect(telegram.stop).toHaveBeenCalledTimes(1);\n expect(slack.stop).toHaveBeenCalledTimes(1);\n });\n\n it('should work with no adapters', async () => {\n await expect(manager.stopAll()).resolves.toBeUndefined();\n });\n });\n});\n"],"names":["ChannelManager","createMockAdapter","type","start","vi","fn","mockResolvedValue","undefined","stop","sendMessage","messageId","onMessage","isHealthy","describe","manager","beforeEach","it","adapter","registerAdapter","expect","getAdapter","toBe","adapter1","adapter2","toThrow","toBeUndefined","telegram","slack","startAll","toHaveBeenCalledTimes","resolves","stopAll"],"mappings":"AACA,SAASA,cAAc,QAAQ,uBAAuB;AAGtD,SAASC,kBAAkBC,IAAY;IACnC,OAAO;QACHA;QACAC,OAAOC,GAAGC,EAAE,GAAGC,iBAAiB,CAACC;QACjCC,MAAMJ,GAAGC,EAAE,GAAGC,iBAAiB,CAACC;QAChCE,aAAaL,GAAGC,EAAE,GAAGC,iBAAiB,CAAC;YAAEI,WAAW;QAAI;QACxDC,WAAWP,GAAGC,EAAE;QAChBO,WAAWR,GAAGC,EAAE,GAAGC,iBAAiB,CAAC;IACzC;AACJ;AAEAO,SAAS,kBAAkB;IACvB,IAAIC;IAEJC,WAAW;QACPD,UAAU,IAAId;IAClB;IAEAa,SAAS,mBAAmB;QACxBG,GAAG,8BAA8B;YAC7B,MAAMC,UAAUhB,kBAAkB;YAClCa,QAAQI,eAAe,CAACD;YACxBE,OAAOL,QAAQM,UAAU,CAAC,aAAaC,IAAI,CAACJ;QAChD;QAEAD,GAAG,0CAA0C;YACzC,MAAMM,WAAWrB,kBAAkB;YACnC,MAAMsB,WAAWtB,kBAAkB;YACnCa,QAAQI,eAAe,CAACI;YACxBH,OAAO,IAAML,QAAQI,eAAe,CAACK,WAAWC,OAAO,CACnD;QAER;IACJ;IAEAX,SAAS,cAAc;QACnBG,GAAG,iDAAiD;YAChDG,OAAOL,QAAQM,UAAU,CAAC,UAAUK,aAAa;QACrD;QAEAT,GAAG,wCAAwC;YACvC,MAAMC,UAAUhB,kBAAkB;YAClCa,QAAQI,eAAe,CAACD;YACxBE,OAAOL,QAAQM,UAAU,CAAC,aAAaC,IAAI,CAACJ;QAChD;IACJ;IAEAJ,SAAS,YAAY;QACjBG,GAAG,kDAAkD;YACjD,MAAMU,WAAWzB,kBAAkB;YACnC,MAAM0B,QAAQ1B,kBAAkB;YAChCa,QAAQI,eAAe,CAACQ;YACxBZ,QAAQI,eAAe,CAACS;YAExB,MAAMb,QAAQc,QAAQ;YAEtBT,OAAOO,SAASvB,KAAK,EAAE0B,qBAAqB,CAAC;YAC7CV,OAAOQ,MAAMxB,KAAK,EAAE0B,qBAAqB,CAAC;QAC9C;QAEAb,GAAG,gCAAgC;YAC/B,MAAMG,OAAOL,QAAQc,QAAQ,IAAIE,QAAQ,CAACL,aAAa;QAC3D;IACJ;IAEAZ,SAAS,WAAW;QAChBG,GAAG,iDAAiD;YAChD,MAAMU,WAAWzB,kBAAkB;YACnC,MAAM0B,QAAQ1B,kBAAkB;YAChCa,QAAQI,eAAe,CAACQ;YACxBZ,QAAQI,eAAe,CAACS;YAExB,MAAMb,QAAQiB,OAAO;YAErBZ,OAAOO,SAASlB,IAAI,EAAEqB,qBAAqB,CAAC;YAC5CV,OAAOQ,MAAMnB,IAAI,EAAEqB,qBAAqB,CAAC;QAC7C;QAEAb,GAAG,gCAAgC;YAC/B,MAAMG,OAAOL,QAAQiB,OAAO,IAAID,QAAQ,CAACL,aAAa;QAC1D;IACJ;AACJ"}
@@ -2,6 +2,7 @@ import * as fs from 'fs';
2
2
  import * as path from 'path';
3
3
  import * as os from 'os';
4
4
  import { ConfigStore } from '../ConfigStore.js';
5
+ import { isSlackEntry } from '../types.js';
5
6
  describe('ConfigStore', ()=>{
6
7
  let tmpDir;
7
8
  let configPath;
@@ -26,6 +27,21 @@ describe('ConfigStore', ()=>{
26
27
  botUsername: 'test_bot'
27
28
  }
28
29
  };
30
+ const slackEntry = {
31
+ type: 'slack',
32
+ enabled: true,
33
+ createdAt: '2026-08-06T00:00:00Z',
34
+ config: {
35
+ appToken: 'xapp-fake',
36
+ botToken: 'xoxb-fake',
37
+ appId: 'A123',
38
+ botUserId: 'U-BOT',
39
+ workspaceId: 'T123',
40
+ workspaceName: 'Sandbox',
41
+ transport: 'socket-mode',
42
+ audience: 'dm'
43
+ }
44
+ };
29
45
  describe('constructor', ()=>{
30
46
  it('should use default path when no configPath provided', async ()=>{
31
47
  const defaultStore = new ConfigStore();
@@ -35,6 +51,11 @@ describe('ConfigStore', ()=>{
35
51
  });
36
52
  });
37
53
  describe('getConfig', ()=>{
54
+ it('narrows provider configuration from the channel type', ()=>{
55
+ expect(isSlackEntry(slackEntry)).toBe(true);
56
+ if (!isSlackEntry(slackEntry)) throw new Error('expected Slack entry');
57
+ expect(slackEntry.config.workspaceId).toBe('T123');
58
+ });
38
59
  it('should return default empty config when file does not exist', async ()=>{
39
60
  const config = await store.getConfig();
40
61
  expect(config).toEqual({
@@ -77,17 +98,25 @@ describe('ConfigStore', ()=>{
77
98
  const mode = (stats.mode & 0o777).toString(8);
78
99
  expect(mode).toBe('600');
79
100
  });
101
+ it('should repair permissive permissions on an existing config file', async ()=>{
102
+ fs.writeFileSync(configPath, JSON.stringify({
103
+ channels: {}
104
+ }), {
105
+ mode: 0o644
106
+ });
107
+ fs.chmodSync(configPath, 0o644);
108
+ await store.saveChannel('slack', slackEntry);
109
+ expect(fs.statSync(configPath).mode & 0o777).toBe(0o600);
110
+ });
80
111
  it('should preserve existing channels when adding a new one', async ()=>{
81
112
  await store.saveChannel('telegram', sampleEntry);
82
- await store.saveChannel('slack', {
83
- ...sampleEntry,
84
- type: 'slack'
85
- });
113
+ await store.saveChannel('slack', slackEntry);
86
114
  const config = await store.getConfig();
87
115
  expect(Object.keys(config.channels)).toEqual([
88
116
  'telegram',
89
117
  'slack'
90
118
  ]);
119
+ expect(config.channels.slack).toEqual(slackEntry);
91
120
  });
92
121
  it('should preserve separate Telegram configs by channel name', async ()=>{
93
122
  await store.saveChannel('personal', {
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/__tests__/ConfigStore.test.ts"],"sourcesContent":["import * as fs from 'fs';\nimport * as path from 'path';\nimport * as os from 'os';\nimport { ConfigStore } from '../ConfigStore.js';\nimport type { ChannelEntry } from '../types.js';\n\ndescribe('ConfigStore', () => {\n let tmpDir: string;\n let configPath: string;\n let store: ConfigStore;\n\n beforeEach(() => {\n tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'channel-connector-test-'));\n configPath = path.join(tmpDir, 'channels.json');\n store = new ConfigStore(configPath);\n });\n\n afterEach(() => {\n fs.rmSync(tmpDir, { recursive: true, force: true });\n });\n\n const sampleEntry: ChannelEntry = {\n type: 'telegram',\n enabled: true,\n createdAt: '2026-04-11T00:00:00Z',\n config: {\n botToken: 'test-token-123',\n botUsername: 'test_bot',\n },\n };\n\n describe('constructor', () => {\n it('should use default path when no configPath provided', async () => {\n const defaultStore = new ConfigStore();\n // Should not throw — just uses default path\n const config = await defaultStore.getConfig();\n expect(config).toBeDefined();\n });\n });\n\n describe('getConfig', () => {\n it('should return default empty config when file does not exist', async () => {\n const config = await store.getConfig();\n expect(config).toEqual({ channels: {} });\n });\n\n it('should return parsed config when file exists', async () => {\n fs.writeFileSync(configPath, JSON.stringify({\n channels: { telegram: sampleEntry }\n }));\n\n const config = await store.getConfig();\n expect(config.channels.telegram).toEqual(sampleEntry);\n });\n\n it('should handle corrupted JSON gracefully', async () => {\n fs.writeFileSync(configPath, 'not valid json{{{');\n\n const config = await store.getConfig();\n expect(config).toEqual({ channels: {} });\n });\n });\n\n describe('saveChannel', () => {\n it('should create config file with channel entry', async () => {\n await store.saveChannel('telegram', sampleEntry);\n\n const raw = fs.readFileSync(configPath, 'utf-8');\n const config = JSON.parse(raw);\n expect(config.channels.telegram).toEqual(sampleEntry);\n });\n\n it('should create parent directory if missing', async () => {\n const nestedPath = path.join(tmpDir, 'nested', 'dir', 'channels.json');\n const nestedStore = new ConfigStore(nestedPath);\n\n await nestedStore.saveChannel('telegram', sampleEntry);\n\n expect(fs.existsSync(nestedPath)).toBe(true);\n });\n\n it('should set file permissions to 0600', async () => {\n await store.saveChannel('telegram', sampleEntry);\n\n const stats = fs.statSync(configPath);\n const mode = (stats.mode & 0o777).toString(8);\n expect(mode).toBe('600');\n });\n\n it('should preserve existing channels when adding a new one', async () => {\n await store.saveChannel('telegram', sampleEntry);\n await store.saveChannel('slack', { ...sampleEntry, type: 'slack' });\n\n const config = await store.getConfig();\n expect(Object.keys(config.channels)).toEqual(['telegram', 'slack']);\n });\n\n it('should preserve separate Telegram configs by channel name', async () => {\n await store.saveChannel('personal', {\n ...sampleEntry,\n config: {\n botToken: 'personal-token',\n botUsername: 'personal_bot',\n authorizedChatId: 111,\n },\n });\n await store.saveChannel('work', {\n ...sampleEntry,\n config: {\n botToken: 'work-token',\n botUsername: 'work_bot',\n authorizedChatId: 222,\n },\n });\n\n const config = await store.getConfig();\n expect(config.channels.personal.config).toEqual({\n botToken: 'personal-token',\n botUsername: 'personal_bot',\n authorizedChatId: 111,\n });\n expect(config.channels.work.config).toEqual({\n botToken: 'work-token',\n botUsername: 'work_bot',\n authorizedChatId: 222,\n });\n });\n });\n\n describe('removeChannel', () => {\n it('should remove a channel entry', async () => {\n await store.saveChannel('telegram', sampleEntry);\n await store.removeChannel('telegram');\n\n const config = await store.getConfig();\n expect(config.channels.telegram).toBeUndefined();\n });\n\n it('should not throw when removing non-existent channel', async () => {\n await expect(store.removeChannel('nonexistent')).resolves.toBeUndefined();\n });\n });\n\n describe('getChannel', () => {\n it('should return the channel entry', async () => {\n await store.saveChannel('telegram', sampleEntry);\n\n const entry = await store.getChannel('telegram');\n expect(entry).toEqual(sampleEntry);\n });\n\n it('should return undefined for non-existent channel', async () => {\n const entry = await store.getChannel('slack');\n expect(entry).toBeUndefined();\n });\n });\n});\n"],"names":["fs","path","os","ConfigStore","describe","tmpDir","configPath","store","beforeEach","mkdtempSync","join","tmpdir","afterEach","rmSync","recursive","force","sampleEntry","type","enabled","createdAt","config","botToken","botUsername","it","defaultStore","getConfig","expect","toBeDefined","toEqual","channels","writeFileSync","JSON","stringify","telegram","saveChannel","raw","readFileSync","parse","nestedPath","nestedStore","existsSync","toBe","stats","statSync","mode","toString","Object","keys","authorizedChatId","personal","work","removeChannel","toBeUndefined","resolves","entry","getChannel"],"mappings":"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"}
1
+ {"version":3,"sources":["../../src/__tests__/ConfigStore.test.ts"],"sourcesContent":["import * as fs from 'fs';\nimport * as path from 'path';\nimport * as os from 'os';\nimport { ConfigStore } from '../ConfigStore.js';\nimport { isSlackEntry } from '../types.js';\nimport type { ChannelEntry } from '../types.js';\n\ndescribe('ConfigStore', () => {\n let tmpDir: string;\n let configPath: string;\n let store: ConfigStore;\n\n beforeEach(() => {\n tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'channel-connector-test-'));\n configPath = path.join(tmpDir, 'channels.json');\n store = new ConfigStore(configPath);\n });\n\n afterEach(() => {\n fs.rmSync(tmpDir, { recursive: true, force: true });\n });\n\n const sampleEntry: ChannelEntry = {\n type: 'telegram',\n enabled: true,\n createdAt: '2026-04-11T00:00:00Z',\n config: {\n botToken: 'test-token-123',\n botUsername: 'test_bot',\n },\n };\n\n const slackEntry: ChannelEntry = {\n type: 'slack',\n enabled: true,\n createdAt: '2026-08-06T00:00:00Z',\n config: {\n appToken: 'xapp-fake',\n botToken: 'xoxb-fake',\n appId: 'A123',\n botUserId: 'U-BOT',\n workspaceId: 'T123',\n workspaceName: 'Sandbox',\n transport: 'socket-mode',\n audience: 'dm',\n },\n };\n\n describe('constructor', () => {\n it('should use default path when no configPath provided', async () => {\n const defaultStore = new ConfigStore();\n // Should not throw — just uses default path\n const config = await defaultStore.getConfig();\n expect(config).toBeDefined();\n });\n });\n\n describe('getConfig', () => {\n it('narrows provider configuration from the channel type', () => {\n expect(isSlackEntry(slackEntry)).toBe(true);\n if (!isSlackEntry(slackEntry)) throw new Error('expected Slack entry');\n expect(slackEntry.config.workspaceId).toBe('T123');\n });\n\n it('should return default empty config when file does not exist', async () => {\n const config = await store.getConfig();\n expect(config).toEqual({ channels: {} });\n });\n\n it('should return parsed config when file exists', async () => {\n fs.writeFileSync(configPath, JSON.stringify({\n channels: { telegram: sampleEntry }\n }));\n\n const config = await store.getConfig();\n expect(config.channels.telegram).toEqual(sampleEntry);\n });\n\n it('should handle corrupted JSON gracefully', async () => {\n fs.writeFileSync(configPath, 'not valid json{{{');\n\n const config = await store.getConfig();\n expect(config).toEqual({ channels: {} });\n });\n });\n\n describe('saveChannel', () => {\n it('should create config file with channel entry', async () => {\n await store.saveChannel('telegram', sampleEntry);\n\n const raw = fs.readFileSync(configPath, 'utf-8');\n const config = JSON.parse(raw);\n expect(config.channels.telegram).toEqual(sampleEntry);\n });\n\n it('should create parent directory if missing', async () => {\n const nestedPath = path.join(tmpDir, 'nested', 'dir', 'channels.json');\n const nestedStore = new ConfigStore(nestedPath);\n\n await nestedStore.saveChannel('telegram', sampleEntry);\n\n expect(fs.existsSync(nestedPath)).toBe(true);\n });\n\n it('should set file permissions to 0600', async () => {\n await store.saveChannel('telegram', sampleEntry);\n\n const stats = fs.statSync(configPath);\n const mode = (stats.mode & 0o777).toString(8);\n expect(mode).toBe('600');\n });\n\n it('should repair permissive permissions on an existing config file', async () => {\n fs.writeFileSync(configPath, JSON.stringify({ channels: {} }), { mode: 0o644 });\n fs.chmodSync(configPath, 0o644);\n\n await store.saveChannel('slack', slackEntry);\n\n expect(fs.statSync(configPath).mode & 0o777).toBe(0o600);\n });\n\n it('should preserve existing channels when adding a new one', async () => {\n await store.saveChannel('telegram', sampleEntry);\n await store.saveChannel('slack', slackEntry);\n\n const config = await store.getConfig();\n expect(Object.keys(config.channels)).toEqual(['telegram', 'slack']);\n expect(config.channels.slack).toEqual(slackEntry);\n });\n\n it('should preserve separate Telegram configs by channel name', async () => {\n await store.saveChannel('personal', {\n ...sampleEntry,\n config: {\n botToken: 'personal-token',\n botUsername: 'personal_bot',\n authorizedChatId: 111,\n },\n });\n await store.saveChannel('work', {\n ...sampleEntry,\n config: {\n botToken: 'work-token',\n botUsername: 'work_bot',\n authorizedChatId: 222,\n },\n });\n\n const config = await store.getConfig();\n expect(config.channels.personal.config).toEqual({\n botToken: 'personal-token',\n botUsername: 'personal_bot',\n authorizedChatId: 111,\n });\n expect(config.channels.work.config).toEqual({\n botToken: 'work-token',\n botUsername: 'work_bot',\n authorizedChatId: 222,\n });\n });\n });\n\n describe('removeChannel', () => {\n it('should remove a channel entry', async () => {\n await store.saveChannel('telegram', sampleEntry);\n await store.removeChannel('telegram');\n\n const config = await store.getConfig();\n expect(config.channels.telegram).toBeUndefined();\n });\n\n it('should not throw when removing non-existent channel', async () => {\n await expect(store.removeChannel('nonexistent')).resolves.toBeUndefined();\n });\n });\n\n describe('getChannel', () => {\n it('should return the channel entry', async () => {\n await store.saveChannel('telegram', sampleEntry);\n\n const entry = await store.getChannel('telegram');\n expect(entry).toEqual(sampleEntry);\n });\n\n it('should return undefined for non-existent channel', async () => {\n const entry = await store.getChannel('slack');\n expect(entry).toBeUndefined();\n });\n });\n});\n"],"names":["fs","path","os","ConfigStore","isSlackEntry","describe","tmpDir","configPath","store","beforeEach","mkdtempSync","join","tmpdir","afterEach","rmSync","recursive","force","sampleEntry","type","enabled","createdAt","config","botToken","botUsername","slackEntry","appToken","appId","botUserId","workspaceId","workspaceName","transport","audience","it","defaultStore","getConfig","expect","toBeDefined","toBe","Error","toEqual","channels","writeFileSync","JSON","stringify","telegram","saveChannel","raw","readFileSync","parse","nestedPath","nestedStore","existsSync","stats","statSync","mode","toString","chmodSync","Object","keys","slack","authorizedChatId","personal","work","removeChannel","toBeUndefined","resolves","entry","getChannel"],"mappings":"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"}
@@ -0,0 +1,429 @@
1
+ import { EventEmitter } from 'node:events';
2
+ import { SlackAdapter, validateSlackAppToken, validateSlackCredentials } from '../../adapters/SlackAdapter.js';
3
+ let FakeSocketClient = class FakeSocketClient extends EventEmitter {
4
+ start = vi.fn().mockResolvedValue(undefined);
5
+ disconnect = vi.fn().mockResolvedValue(undefined);
6
+ };
7
+ const config = {
8
+ appToken: 'xapp-fake',
9
+ botToken: 'xoxb-fake',
10
+ appId: 'A123',
11
+ botUserId: 'U-BOT',
12
+ workspaceId: 'T123',
13
+ transport: 'socket-mode',
14
+ audience: 'dm'
15
+ };
16
+ function envelope(overrides = {}) {
17
+ return {
18
+ type: 'events_api',
19
+ ack: vi.fn().mockResolvedValue(undefined),
20
+ body: {
21
+ team_id: 'T123',
22
+ event_id: 'Ev123',
23
+ event: {
24
+ type: 'message',
25
+ channel_type: 'im',
26
+ channel: 'D123',
27
+ user: 'U123',
28
+ text: 'run tests',
29
+ ts: '100.1',
30
+ event_ts: '100.1',
31
+ ...overrides
32
+ }
33
+ }
34
+ };
35
+ }
36
+ describe('SlackAdapter', ()=>{
37
+ it('validates bot identity without returning either credential', async ()=>{
38
+ const authTest = vi.fn().mockResolvedValue({
39
+ ok: true,
40
+ app_id: 'A123',
41
+ user_id: 'U-BOT',
42
+ team_id: 'T123',
43
+ team: 'Sandbox'
44
+ });
45
+ await expect(validateSlackCredentials('xoxb-fake', {
46
+ authTest
47
+ })).resolves.toEqual({
48
+ appId: 'A123',
49
+ botUserId: 'U-BOT',
50
+ workspaceId: 'T123',
51
+ workspaceName: 'Sandbox'
52
+ });
53
+ });
54
+ it('accepts Slack documented bot identity without an app ID', async ()=>{
55
+ const authTest = vi.fn().mockResolvedValue({
56
+ ok: true,
57
+ bot_id: 'B123',
58
+ user_id: 'U-BOT',
59
+ team_id: 'T123',
60
+ team: 'Sandbox'
61
+ });
62
+ await expect(validateSlackCredentials('xoxb-fake', {
63
+ authTest
64
+ })).resolves.toEqual({
65
+ botUserId: 'U-BOT',
66
+ workspaceId: 'T123',
67
+ workspaceName: 'Sandbox'
68
+ });
69
+ });
70
+ it('rejects incomplete Slack credential identity', async ()=>{
71
+ const authTest = vi.fn().mockResolvedValue({
72
+ ok: true,
73
+ team_id: 'T123'
74
+ });
75
+ await expect(validateSlackCredentials('xoxb-fake', {
76
+ authTest
77
+ })).rejects.toThrow('Slack bot token returned incomplete identity');
78
+ });
79
+ it('validates an app token through apps.connections.open', async ()=>{
80
+ const open = vi.fn().mockResolvedValue({
81
+ ok: true,
82
+ url: 'wss://wss-primary.slack.com/link/'
83
+ });
84
+ await expect(validateSlackAppToken('xapp-fake', {
85
+ open
86
+ })).resolves.toBeUndefined();
87
+ expect(open).toHaveBeenCalledOnce();
88
+ });
89
+ it('rejects an app token that cannot open Socket Mode', async ()=>{
90
+ const open = vi.fn().mockResolvedValue({
91
+ ok: false
92
+ });
93
+ await expect(validateSlackAppToken('xapp-fake', {
94
+ open
95
+ })).rejects.toThrow('Slack app token cannot open Socket Mode');
96
+ });
97
+ it('starts and stops the official Socket Mode client and reports health', async ()=>{
98
+ const socket = new FakeSocketClient();
99
+ const adapter = new SlackAdapter(config, {
100
+ socketClient: socket,
101
+ webClient: {
102
+ chat: {
103
+ postMessage: vi.fn()
104
+ }
105
+ }
106
+ });
107
+ expect(await adapter.isHealthy()).toBe(false);
108
+ await adapter.start();
109
+ expect(socket.start).toHaveBeenCalledOnce();
110
+ expect(await adapter.isHealthy()).toBe(true);
111
+ await adapter.stop();
112
+ expect(socket.disconnect).toHaveBeenCalledOnce();
113
+ expect(await adapter.isHealthy()).toBe(false);
114
+ });
115
+ it('does not register duplicate listeners across restarts', async ()=>{
116
+ const socket = new FakeSocketClient();
117
+ const adapter = new SlackAdapter(config, {
118
+ socketClient: socket,
119
+ webClient: {
120
+ chat: {
121
+ postMessage: vi.fn()
122
+ }
123
+ }
124
+ });
125
+ await adapter.start();
126
+ await adapter.stop();
127
+ await adapter.start();
128
+ expect(socket.listenerCount('slack_event')).toBe(1);
129
+ expect(socket.listenerCount('interactive')).toBe(1);
130
+ socket.emit('disconnected');
131
+ expect(await adapter.isHealthy()).toBe(false);
132
+ socket.emit('connected');
133
+ expect(await adapter.isHealthy()).toBe(true);
134
+ });
135
+ it('acknowledges before delivering one normalized workspace DM event', async ()=>{
136
+ const socket = new FakeSocketClient();
137
+ const adapter = new SlackAdapter(config, {
138
+ socketClient: socket,
139
+ webClient: {
140
+ chat: {
141
+ postMessage: vi.fn()
142
+ }
143
+ }
144
+ });
145
+ const order = [];
146
+ adapter.onMessage(vi.fn(async (message)=>{
147
+ order.push('handler');
148
+ expect(message).toMatchObject({
149
+ channelType: 'slack',
150
+ chatId: 'D123',
151
+ userId: 'U123',
152
+ workspaceId: 'T123',
153
+ messageId: 'Ev123',
154
+ text: 'run tests'
155
+ });
156
+ }));
157
+ await adapter.start();
158
+ const item = envelope();
159
+ item.ack.mockImplementation(async ()=>{
160
+ order.push('ack');
161
+ });
162
+ socket.emit('slack_event', item);
163
+ await vi.waitFor(()=>expect(order).toEqual([
164
+ 'ack',
165
+ 'handler'
166
+ ]));
167
+ socket.emit('slack_event', item);
168
+ await new Promise((resolve)=>setTimeout(resolve, 0));
169
+ expect(order).toEqual([
170
+ 'ack',
171
+ 'handler',
172
+ 'ack'
173
+ ]);
174
+ });
175
+ it.each([
176
+ [
177
+ 'wrong workspace',
178
+ {},
179
+ {
180
+ team_id: 'T999'
181
+ }
182
+ ],
183
+ [
184
+ 'bot message',
185
+ {
186
+ bot_id: 'B123'
187
+ },
188
+ {}
189
+ ],
190
+ [
191
+ 'self message',
192
+ {
193
+ user: 'U-BOT'
194
+ },
195
+ {}
196
+ ],
197
+ [
198
+ 'subtype',
199
+ {
200
+ subtype: 'message_changed'
201
+ },
202
+ {}
203
+ ],
204
+ [
205
+ 'non-DM',
206
+ {
207
+ channel_type: 'channel'
208
+ },
209
+ {}
210
+ ]
211
+ ])('ignores %s events', async (_name, eventOverrides, bodyOverrides)=>{
212
+ const socket = new FakeSocketClient();
213
+ const handler = vi.fn();
214
+ const adapter = new SlackAdapter(config, {
215
+ socketClient: socket,
216
+ webClient: {
217
+ chat: {
218
+ postMessage: vi.fn()
219
+ }
220
+ }
221
+ });
222
+ adapter.onMessage(handler);
223
+ await adapter.start();
224
+ const item = envelope(eventOverrides);
225
+ Object.assign(item.body, bodyOverrides);
226
+ socket.emit('slack_event', item);
227
+ await vi.waitFor(()=>expect(item.ack).toHaveBeenCalled());
228
+ expect(handler).not.toHaveBeenCalled();
229
+ });
230
+ it('delivers an ordinary workspace DM immediately without pairing', async ()=>{
231
+ const socket = new FakeSocketClient();
232
+ const handler = vi.fn();
233
+ const adapter = new SlackAdapter(config, {
234
+ socketClient: socket,
235
+ webClient: {
236
+ chat: {
237
+ postMessage: vi.fn()
238
+ }
239
+ }
240
+ });
241
+ adapter.onMessage(handler);
242
+ await adapter.start();
243
+ socket.emit('slack_event', envelope({
244
+ text: 'run tests'
245
+ }));
246
+ await vi.waitFor(()=>expect(handler).toHaveBeenCalledWith(expect.objectContaining({
247
+ chatId: 'D123',
248
+ userId: 'U123',
249
+ text: 'run tests',
250
+ workspaceId: 'T123'
251
+ })));
252
+ });
253
+ it('sends an accessible Block Kit question and finalizes its actions', async ()=>{
254
+ const socket = new FakeSocketClient();
255
+ const postMessage = vi.fn().mockResolvedValue({
256
+ ok: true,
257
+ ts: '400.1'
258
+ });
259
+ const update = vi.fn().mockResolvedValue({
260
+ ok: true
261
+ });
262
+ const adapter = new SlackAdapter(config, {
263
+ socketClient: socket,
264
+ webClient: {
265
+ chat: {
266
+ postMessage,
267
+ update
268
+ }
269
+ }
270
+ });
271
+ await expect(adapter.sendQuestion('D123', {
272
+ id: 'q1',
273
+ header: 'Scope',
274
+ question: 'Choose one',
275
+ allowSkip: true,
276
+ options: [
277
+ {
278
+ label: 'Safe',
279
+ description: 'Read only',
280
+ value: '1'
281
+ }
282
+ ]
283
+ })).resolves.toEqual({
284
+ messageId: '400.1'
285
+ });
286
+ expect(postMessage).toHaveBeenCalledWith(expect.objectContaining({
287
+ channel: 'D123',
288
+ text: 'Scope: Choose one',
289
+ blocks: expect.any(Array)
290
+ }));
291
+ await adapter.finalizeInteraction('D123', '400.1');
292
+ expect(update).toHaveBeenCalledWith({
293
+ channel: 'D123',
294
+ ts: '400.1',
295
+ blocks: []
296
+ });
297
+ });
298
+ it('escapes Slack control syntax in question fallback text', async ()=>{
299
+ const postMessage = vi.fn().mockResolvedValue({
300
+ ok: true,
301
+ ts: '400.1'
302
+ });
303
+ const adapter = new SlackAdapter(config, {
304
+ socketClient: new FakeSocketClient(),
305
+ webClient: {
306
+ chat: {
307
+ postMessage
308
+ }
309
+ }
310
+ });
311
+ await adapter.sendQuestion('D123', {
312
+ id: 'q1',
313
+ header: '<!channel>',
314
+ question: 'Choose <one> & continue',
315
+ allowSkip: false,
316
+ options: [
317
+ {
318
+ label: 'Safe',
319
+ value: '1'
320
+ }
321
+ ]
322
+ });
323
+ expect(postMessage).toHaveBeenCalledWith(expect.objectContaining({
324
+ text: '&lt;!channel&gt;: Choose &lt;one&gt; &amp; continue'
325
+ }));
326
+ });
327
+ it('acknowledges and delivers one valid workspace block action', async ()=>{
328
+ const socket = new FakeSocketClient();
329
+ const handler = vi.fn();
330
+ const adapter = new SlackAdapter(config, {
331
+ socketClient: socket,
332
+ webClient: {
333
+ chat: {
334
+ postMessage: vi.fn()
335
+ }
336
+ }
337
+ });
338
+ adapter.onInteraction(handler);
339
+ await adapter.start();
340
+ const item = {
341
+ ack: vi.fn().mockResolvedValue(undefined),
342
+ body: {
343
+ type: 'block_actions',
344
+ team: {
345
+ id: 'T123'
346
+ },
347
+ user: {
348
+ id: 'U123'
349
+ },
350
+ channel: {
351
+ id: 'D123'
352
+ },
353
+ container: {
354
+ message_ts: '400.1'
355
+ },
356
+ actions: [
357
+ {
358
+ action_id: 'ai_devkit_question',
359
+ action_ts: '401.1',
360
+ value: 'q1:1'
361
+ }
362
+ ]
363
+ }
364
+ };
365
+ socket.emit('interactive', item);
366
+ await vi.waitFor(()=>expect(handler).toHaveBeenCalledOnce());
367
+ expect(item.ack).toHaveBeenCalledOnce();
368
+ expect(handler).toHaveBeenCalledWith(expect.objectContaining({
369
+ workspaceId: 'T123',
370
+ chatId: 'D123',
371
+ userId: 'U123',
372
+ messageId: '400.1',
373
+ value: 'q1:1'
374
+ }));
375
+ socket.emit('interactive', item);
376
+ await new Promise((resolve)=>setTimeout(resolve, 0));
377
+ expect(handler).toHaveBeenCalledOnce();
378
+ expect(item.ack).toHaveBeenCalledTimes(2);
379
+ });
380
+ it('does not acknowledge interactive envelopes a second time on the generic event stream', async ()=>{
381
+ const socket = new FakeSocketClient();
382
+ const adapter = new SlackAdapter(config, {
383
+ socketClient: socket,
384
+ webClient: {
385
+ chat: {
386
+ postMessage: vi.fn()
387
+ }
388
+ }
389
+ });
390
+ await adapter.start();
391
+ const ack = vi.fn().mockResolvedValue(undefined);
392
+ socket.emit('slack_event', {
393
+ type: 'interactive',
394
+ ack,
395
+ body: {}
396
+ });
397
+ await new Promise((resolve)=>setTimeout(resolve, 0));
398
+ expect(ack).not.toHaveBeenCalled();
399
+ });
400
+ it('acknowledges malformed and wrong-workspace block actions without delivery', async ()=>{
401
+ const socket = new FakeSocketClient();
402
+ const handler = vi.fn();
403
+ const adapter = new SlackAdapter(config, {
404
+ socketClient: socket,
405
+ webClient: {
406
+ chat: {
407
+ postMessage: vi.fn()
408
+ }
409
+ }
410
+ });
411
+ adapter.onInteraction(handler);
412
+ await adapter.start();
413
+ const ack = vi.fn().mockResolvedValue(undefined);
414
+ socket.emit('interactive', {
415
+ ack,
416
+ body: {
417
+ type: 'block_actions',
418
+ team: {
419
+ id: 'T999'
420
+ },
421
+ actions: []
422
+ }
423
+ });
424
+ await vi.waitFor(()=>expect(ack).toHaveBeenCalled());
425
+ expect(handler).not.toHaveBeenCalled();
426
+ });
427
+ });
428
+
429
+ //# sourceMappingURL=SlackAdapter.test.js.map