🍊

Pleasanter の通知でチャットツールに自分としてポストする

2022/12/21に公開

2022 個人アドベントカレンダー の記事です。

やりたかったこと

通知機能の API キーが固定のため Bot 専用アカウントでしか送信できない。
あたかも更新者が通知したようにしたい。できるだけ手間をかけない方法で。

  • 手間をかけない方法で、とは。

"ユーザ" が更新者、のときのビューを定義して、ビューの数だけ通知の設定を置けば、API キーを沢山置くことはできるが、ビューが飽和するし、通知のテキストや監視項目などが事後的に変えづらい(最初はコピー可能)

方針

  • API キーとユーザの対を定義するテーブルを作る
    • ユーザは管理者に保持し、重複禁止にしておいて、タイトルにキーを書くなど。
  • サーバスクリプトの
    • 更新前で
      • 通知設定を読み取り
      • 変更状態の監視に相当する処理を実施
      • API キーを定義テーブルから取ってくる
      • Disabled とトークンの設定を上書き
    • 更新後で送信

コアとなる構成は、context.UserData が更新前後で内容を共有できること、オブジェクトをそのままつっこめること、通知設定を無効にしておくと notification.Send しても送信できないこと

実装

ChatWork で試しました。

準備

ChatWork のルーム ID とユーザ毎の API キーを取得します。

API キーを定義するテーブル

  • テーブルの管理のエディタタブで
    • 管理者とタイトルのみ有効化
    • 管理者は、入力必須、重複禁止に指定。
    • タイトルは、入力必須、重複禁止に指定。

上記のように設定したら、あらかじめ確認した API キーをユーザごとに設定します。

API キーを取得して指定していくのはそれなりに煩雑なので、運用としては、ここのテーブルは個々人が任意に設定する方法でもいいかもしれないです。
ここで設定をすれば、更新ユーザが発言したことになるけど、設定しなれけばボットの発言になる、という動作になりますので。

データ利用するテーブル

通知の設定を次のようにします。

  • 通知種別を ChatWork に指定
  • 無効にチェック

アドレスやトークンはマニュアルに添って指定してください。
ここでのトークンは前述のテーブルで設定がないユーザでも送信ができるようにするためのものなので、それと分かるアカウントの API キーがよいです。

また、ビューによる条件など詳細は後述

サーバスクリプト

  • 更新
const defaultKeys = (model) => {
  const keys = Object.keys(model);
  const systems = [
    'Ver',
    'SiteId',
    'Creator',
    'Updator',
    'CreatedTime',
    'UpdatedTime',
    'ResultId',
    'IssueId',
    'Comment', // model はコメントに対応していない
  ];
  const excludeTypes = ['function', 'undefined'];
  return keys
    .filter((k) => !systems.includes(k))
    .filter((k) => !excludeTypes.includes(typeof model[k]));
};
const defaultFormat = (model) =>
  `{Url}
${defaultKeys(model)
  .map((k) => JSON.stringify(new NotificationLine(k)))
  .join('\n')})}

{UserName}<{MailAddress}>`;
const makeTitle = (title, action) => {
  if (action === 'create') {
    return `${title}を作成しました。`;
  }
  if (action === 'update') {
    return `${title}を更新しました。`;
  }
  return '';
};
const userIdToName = (id) => users.Get(id).Name;
const myKeyOrEmpty = (id) => {
  const records = items.Get(
    7168099,
    `{"View":{"ColumnFilterHash":{"Manager":"[\\\"${id}\\\"]"}}}`,
  );
  if (records.Length !== 1) {
    return '';
  }
  return records[0].Title;
};

const wrapTitle = (title, prefix, method) => {
  const char = method > 1 ? '*' : '';
  return method === 9 ? '' : `${prefix}${char}${title}${char}`;
};
const monitorChangesColumns = (prev, post) =>
  defaultKeys(post).some((e) => `${prev[e]}` !== `${post[e]}`);
const decodeFormat = (text, prev, post) =>
  text
    .split('\n')
    .map((l) => new NotificationLine(l).toString(context, prev, post))
    .filter((e) => e !== '')
    .join('\n');
class NotificationLine {
  #displayTypesEnum = ['BeforeAndAfterChange', 'BeforeChange', 'AfterChange'];
  #original = '';
  Name = '';
  Prefix = '';
  Delimiter = ' : ';
  Allow = ' => ';
  Always = false;
  DisplayTypes = 'BeforeAndAfterChange';
  ValueOnly = false;
  ConsiderMultiline = true;
  #crlf = '';
  constructor(format = '') {
    const { Status, Value: obj } = JSONparse(format);
    if (Status === false) {
      this.#original = format;
    }
    if (obj === undefined) return;
    this.Name = obj.Name?.replace(/(^\[|\]$)/g, '') ?? ''; // この時点でカラム名になっている。サーバスクリプト内部のオブジェクトで LabelText を取る方法がない。Prefix に項目名を入れるのが現実解か。
    this.Prefix = obj.Prefix ?? '';
    this.Delimiter = obj.Delimiter ?? ' : ';
    this.Allow = obj.Allow ?? ' => ';
    this.Always = obj.Always || false;
    this.DisplayTypes =
      obj.DisplayTypes !== undefined &&
      this.#displayTypesEnum.includes(obj.DisplayTypes)
        ? obj.DisplayTypes
        : 'BeforeAndAfterChange';
    this.ValueOnly = obj.ValueOnly || false;
    this.ConsiderMultiline = obj.ConsiderMultiline === false ? false : true;
    this.#crlf = this.ConsiderMultiline === false ? '' : ''; //: '\n'; MarkDown か判定できないため常に改行を入れてしまう
  }
  toString(context, prev, post) {
    if (this.#original !== '') {
      return this.#replace(context);
    }
    return this.#parse(prev, post);
  }
  #masquerade = (value) => {
    if (this.Name.startsWith('Class')) {
      if (value === null || value === undefined || value === '') {
        return value;
      }
      if (/^\[.*?\]$/.test(value)) {
        return JSON.parse(value).join(',');
      }
    }
    if (this.Name === 'Manager' || this.Name === 'Owner') {
      return Number(value) > 0 ? userIdToName(value) : '';
    }
    return value;
  };
  #replace(context) {
    return this.#original
      .replace('{Url}', context.Url)
      .replace('{LoginId}', context.LoginId)
      .replace('{UserName}', users.Get(context.UserId).Name)
      .replace('{MailAddress}', '');
  }
  #parse(prev, post) {
    const before = this.#masquerade(prev[this.Name]);
    const after = this.#masquerade(post[this.Name]);
    if (before === after && !this.Always) {
      return '';
    }
    return [
      this.ValueOnly
        ? ''
        : `${this.Prefix}${this.Name}${this.Delimiter}${this.#crlf}`,
      this.DisplayTypes.includes('Before') ? before : '',
      this.DisplayTypes.includes('And') ? this.Allow : '',
      this.DisplayTypes.includes('After') ? after : '',
      this.$crlf,
    ]
      .filter((e) => e !== '')
      .join('');
  }
}
const JSONparse = (jsonStr) => {
  let Status = true;
  let Value = undefined;
  try {
    Value = JSON.parse(jsonStr);
  } catch (e) {
    Status = false;
  }
  return { Status, Value };
};
try {
  const notification = notifications.Get(1);
  const title = makeTitle(context.RecordTitle, context.Action);
  notification.Disabled = true;
  if (title !== '') {
    // if (notification.Disabled === true) {
    if (monitorChangesColumns(saved, model)) {
      notification.Disabled = null;
      notification.Body = decodeFormat(
        notification.UseCustomFormat
          ? notification.Format
          : defaultFormat(model),
        saved,
        model,
      );
      notification.Title = title; // notification.Send は Prefix と Title を結合(メール以外だとさらに Body に連結するので、ここで Prefix や * を手当しなくていい)
      const keyCandidate = myKeyOrEmpty(context.UserId);
      if (keyCandidate !== '') {
        notification.Token = keyCandidate;
      }
    }
    // }
  }
  context.UserData.notification = notification;
} catch (e) {
  context.Log(e.stack);
  context.Log(e.message);
}
  • 更新後
//after change
try {
  context.UserData.notification?.Send();
} catch (e) {
  context.Log(e.stack);
  context.Log(e.message);
}

総括

  • 他の通知について

基本的に API トークンがユーザごとに識別できれば他のチャットツールも本人として投稿できるはず。
また、メールは API キーを取る意味がないので、そこは通知の Type(数値) で分岐すれば実装は容易。

  • 通知のメッセージが再現できないことについて

こちらの記事のとおり、サーバスクリプトでは"通知"機能のメッセージ部分を汎用的に再現することが難しい。
もちろん、テーブル構造を知っている前提であれば、コメントの監視ができない、メールアドレスの取得に難がある、こと以外は対応できそうではあるものの、テーブルごとにデータカラムとその意味によって置換処理を書くのは、開発体験が悪すぎると感じた。

  • 通知の条件

変更前後のビューはサーバスクリプトからは取得できない。
機能追加がなされてビューの情報が取得できたとしてもビュー ID になると思われ、またサーバスクリプトの view は、フィルターをスクリプトで作ることはできるもののビューに指定したフィルタをオブジェクトとして取得したり加工できる仕組みがない。
さらに view.Filters で指定しているオブジェクトを使い回したとしても、フィルタをあてたときにマッチするかをうまく判定してくれる機能もない。
といったところから、自力で saved と model に条件をあてる処理をごりごりに書けば条件判定できなくはない、といった感じ。

  • まとめ

「リマインダー」と違って、「通知」は個々のユーザ操作によって発動するので、ユーザとしてメッセージを送るのが体験としては自然だと思い、作ってみようと思いました。

設定インターフェイスを既存機能のものを使い、送信部分だけをスクリプト実行すれば実装の手が抜けるのではないかという想定でしたが、予想に反してそう易々とは作らせてもらえませんでした。

フルにカスタマイズしてコードを作り込めば、できなくはなさそう、という見込は得られましたが、私が開発するのだとしたらこの機能は諦めてもらうか、通知だけ別のシステムを作る[1]かな、といった印象でした。

脚注
  1. 通知機能の HttpClient で投げることにして、その受け皿を Http が喋れて、柔軟な通知先に通知ができる補助ツール的なシステムを作る、といった想定 ↩︎

GitHubで編集を提案

Discussion