diff --git a/.env.example b/.env.example index e092682..f7e8c8d 100644 --- a/.env.example +++ b/.env.example @@ -86,4 +86,8 @@ KIT_MATTERMOST_URL=http://100.68.122.24:8065 KIT_MATTERMOST_TOKEN= KIT_MATTERMOST_USER_ID=zdunkip7xjy1ukn9xd8wt5kqrc JORDAN_MATTERMOST_USER_ID=zh5bhphuqfdtffks1nys4e76ie +LEXI_MATTERMOST_USER_ID=7satyicgxpfq5grdsuty9g3cuw +KIT_MATTERMOST_TEAM=dhoh8n1xkjfgzy7s63ufckb9ko KIT_MATTERMOST_CHANNEL=3r1tnxhe5fgcmjxgrspm7316oc +KIT_MATTERMOST_DM=3r1tnxhe5fgcmjxgrspm7316oc +KIT_MATTERMOST_HALLWAY=9sp18hgiaiybtj9p5nm8uw3sna diff --git a/README.md b/README.md index 2cf776e..15f9a49 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,7 @@ This is Lexi's `instructions()` idea (identity + domain + boot + scratch + missi | `YouTubeTranscript` | Full captions via `yt-dlp` (timestamps). Not Lexi's 8k clip. | | `MemoryStore` / `MemorySearch` | File-backed `knowledge_kilt`. | | `AskLexi` | Shells to `~/.grok/kit/ask-lexi` (her MCP). Taste / life only. | +| `ChannelCreate` | Find or create a Mattermost open channel. `php artisan kit:channel` / `mm create`. | Next (not built): `BlenderRun`, `LookCompare` (run Playwright), `GitHubOpenIssue`. diff --git a/app/Agent/KitAgent.php b/app/Agent/KitAgent.php index 11af12c..979b574 100644 --- a/app/Agent/KitAgent.php +++ b/app/Agent/KitAgent.php @@ -5,6 +5,7 @@ use App\Tools\AskLexi; use App\Tools\BoardWrite; use App\Tools\CatalogRead; +use App\Tools\ChannelCreate; use App\Tools\HeartbeatRead; use App\Tools\LookReport; use App\Tools\MemorySearch; @@ -28,6 +29,7 @@ public function tools(): iterable return [ new CatalogRead, new HeartbeatRead, + new ChannelCreate, new BoardWrite, new LookReport, new YouTubeTranscript, diff --git a/app/Console/Commands/ChannelCommand.php b/app/Console/Commands/ChannelCommand.php new file mode 100644 index 0000000..af92f56 --- /dev/null +++ b/app/Console/Commands/ChannelCommand.php @@ -0,0 +1,45 @@ +createChannel( + (string) $this->argument('name'), + (string) $this->option('display'), + (string) $this->option('purpose'), + ); + if (isset($out['error'])) { + $this->error($out['error']); + + return self::FAILURE; + } + + $add = trim((string) $this->option('add')); + if ($add !== '') { + foreach (preg_split('/\s*,\s*/', $add) ?: [] as $who) { + $uid = $mm->resolveUser($who); + if ($uid !== null) { + $mm->addMember($out['id'], $uid); + } + } + } + + $this->line((($out['created'] ?? false) ? 'created' : 'exists').' '.$out['id']); + + return self::SUCCESS; + } +} diff --git a/app/Mattermost/Client.php b/app/Mattermost/Client.php index 6aeffa1..3f3249d 100644 --- a/app/Mattermost/Client.php +++ b/app/Mattermost/Client.php @@ -2,6 +2,7 @@ namespace App\Mattermost; +use Illuminate\Http\Client\PendingRequest; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; @@ -9,20 +10,109 @@ class Client { public function post(string $channel, string $text): void { - $url = rtrim((string) config('kit.mattermost.url'), '/'); - $token = (string) config('kit.mattermost.token'); - if ($url === '' || $token === '' || $channel === '') { + if ($this->base() === null || $channel === '') { Log::warning('kit mm post skipped: missing url/token/channel'); return; } - Http::timeout(20) - ->withToken($token) - ->acceptJson() - ->post($url.'/api/v4/posts', [ - 'channel_id' => $channel, - 'message' => $text, - ]); + $this->http()->post($this->base().'/api/v4/posts', [ + 'channel_id' => $channel, + 'message' => $text, + ]); + } + + /** + * Find or create an open team channel. Returns id + created flag. + * + * @return array{id: string, name: string, created: bool}|array{error: string} + */ + public function createChannel(string $name, string $display = '', string $purpose = ''): array + { + $slug = $this->slug($name); + if ($slug === '') { + return ['error' => 'channel name required']; + } + $base = $this->base(); + $team = (string) config('kit.mattermost.team_id'); + if ($base === null || $team === '') { + return ['error' => 'mattermost url/token/team missing']; + } + + $existing = $this->http()->get($base.'/api/v4/teams/'.$team.'/channels/name/'.$slug); + if ($existing->successful() && is_string($existing->json('id'))) { + return ['id' => $existing->json('id'), 'name' => $slug, 'created' => false]; + } + + $res = $this->http()->post($base.'/api/v4/channels', [ + 'team_id' => $team, + 'name' => $slug, + 'display_name' => $display !== '' ? $display : $slug, + 'purpose' => $purpose, + 'type' => 'O', + ]); + if (! $res->successful() || ! is_string($res->json('id'))) { + return ['error' => 'create failed: '.($res->json('message') ?? $res->status())]; + } + + return ['id' => $res->json('id'), 'name' => $slug, 'created' => true]; + } + + public function addMember(string $channel, string $userId): bool + { + $base = $this->base(); + if ($base === null || $channel === '' || $userId === '') { + return false; + } + $res = $this->http()->post($base.'/api/v4/channels/'.$channel.'/members', [ + 'user_id' => $userId, + ]); + + return $res->successful(); + } + + public function resolveUser(string $who): ?string + { + $who = ltrim(trim($who), '@'); + if ($who === '') { + return null; + } + $base = $this->base(); + if ($base === null) { + return null; + } + if (strlen($who) === 26 && ctype_alnum($who)) { + return $who; + } + $res = $this->http()->get($base.'/api/v4/users/username/'.$who); + $id = $res->json('id'); + + return is_string($id) && $id !== '' ? $id : null; + } + + private function slug(string $name): string + { + $name = strtolower(trim($name)); + $name = ltrim($name, '#'); + + return preg_replace('/[^a-z0-9-]/', '-', $name) ?? ''; + } + + private function http(): PendingRequest + { + return Http::timeout(20) + ->withToken((string) config('kit.mattermost.token')) + ->acceptJson(); + } + + private function base(): ?string + { + $url = rtrim((string) config('kit.mattermost.url'), '/'); + $token = (string) config('kit.mattermost.token'); + if ($url === '' || $token === '') { + return null; + } + + return $url; } } diff --git a/app/Tools/ChannelCreate.php b/app/Tools/ChannelCreate.php new file mode 100644 index 0000000..ac1616e --- /dev/null +++ b/app/Tools/ChannelCreate.php @@ -0,0 +1,59 @@ + $schema->string()->description('Channel slug, e.g. mesa-studio.')->required(), + 'display' => $schema->string()->description('Optional display name.'), + 'purpose' => $schema->string()->description('Optional purpose line.'), + 'add' => $schema->string()->description('Optional comma @usernames or user ids to add.'), + ]; + } + + public function handle(Request $request): Stringable|string + { + $name = trim((string) ($request['name'] ?? '')); + $mm = app(Client::class); + $out = $mm->createChannel( + $name, + trim((string) ($request['display'] ?? '')), + trim((string) ($request['purpose'] ?? '')), + ); + if (isset($out['error'])) { + return 'channel: '.$out['error']; + } + + $added = []; + $add = trim((string) ($request['add'] ?? '')); + if ($add !== '') { + foreach (preg_split('/\s*,\s*/', $add) ?: [] as $who) { + $uid = $mm->resolveUser($who); + if ($uid !== null && $mm->addMember($out['id'], $uid)) { + $added[] = $who; + } + } + } + + $flag = ($out['created'] ?? false) ? 'created' : 'exists'; + + return $flag.' #'.$out['name'].' id='.$out['id'] + .($added !== [] ? ' added='.implode(',', $added) : ''); + } +} diff --git a/config/kit.php b/config/kit.php index 98abf80..43417ee 100644 --- a/config/kit.php +++ b/config/kit.php @@ -23,6 +23,10 @@ 'token' => env('KIT_MATTERMOST_TOKEN', ''), 'user_id' => env('KIT_MATTERMOST_USER_ID', 'zdunkip7xjy1ukn9xd8wt5kqrc'), 'jordan_user_id' => env('JORDAN_MATTERMOST_USER_ID', 'zh5bhphuqfdtffks1nys4e76ie'), + 'lexi_user_id' => env('LEXI_MATTERMOST_USER_ID', '7satyicgxpfq5grdsuty9g3cuw'), + 'team_id' => env('KIT_MATTERMOST_TEAM', 'dhoh8n1xkjfgzy7s63ufckb9ko'), 'channel_id' => env('KIT_MATTERMOST_CHANNEL', '3r1tnxhe5fgcmjxgrspm7316oc'), + 'hallway_id' => env('KIT_MATTERMOST_HALLWAY', '9sp18hgiaiybtj9p5nm8uw3sna'), + 'dm_id' => env('KIT_MATTERMOST_DM', '3r1tnxhe5fgcmjxgrspm7316oc'), ], ]; diff --git a/tests/Feature/ChannelCreateTest.php b/tests/Feature/ChannelCreateTest.php new file mode 100644 index 0000000..0363768 --- /dev/null +++ b/tests/Feature/ChannelCreateTest.php @@ -0,0 +1,57 @@ + 'http://mm.test', + 'kit.mattermost.token' => 'tok', + 'kit.mattermost.team_id' => 'team-1', + ]); +}); + +test('returns existing channel without creating', function () { + Http::fake([ + 'http://mm.test/api/v4/teams/team-1/channels/name/mesa-studio' => Http::response([ + 'id' => 'ch-exists', + 'name' => 'mesa-studio', + ], 200), + ]); + + $out = (string) (new ChannelCreate)->handle(new Request(['name' => 'mesa-studio'])); + + expect($out)->toBe('exists #mesa-studio id=ch-exists'); + Http::assertNotSent(fn ($req) => $req->url() === 'http://mm.test/api/v4/channels' && $req->method() === 'POST'); +}); + +test('creates an open channel and adds members', function () { + Http::fake([ + 'http://mm.test/api/v4/teams/team-1/channels/name/shop-floor' => Http::response(['id' => 'missing'], 404), + 'http://mm.test/api/v4/channels' => Http::response(['id' => 'ch-new', 'name' => 'shop-floor'], 201), + 'http://mm.test/api/v4/users/username/jordan' => Http::response(['id' => 'u-j'], 200), + 'http://mm.test/api/v4/channels/ch-new/members' => Http::response(['user_id' => 'u-j'], 201), + ]); + + $out = (string) (new ChannelCreate)->handle(new Request([ + 'name' => 'Shop Floor', + 'purpose' => 'factory', + 'add' => '@jordan', + ])); + + expect($out)->toBe('created #shop-floor id=ch-new added=@jordan'); +}); + +test('client slugs names', function () { + Http::fake([ + 'http://mm.test/api/v4/teams/team-1/channels/name/mesa-studio' => Http::response([ + 'id' => 'ch-1', + 'name' => 'mesa-studio', + ], 200), + ]); + + $out = (new Client)->createChannel('#Mesa Studio'); + expect($out)->toMatchArray(['id' => 'ch-1', 'created' => false, 'name' => 'mesa-studio']); +});